feat: US-002 - Modify Tool interface to return *ToolResult
- Update all Tool implementations to return *ToolResult instead of (string, error) - ShellTool: returns UserResult for command output, ErrorResult for failures - SpawnTool: returns NewToolResult on success, ErrorResult on failure - WebTool: returns ToolResult with ForUser=content, ForLLM=summary - EditTool: returns SilentResult for silent edits, ErrorResult on failure - FilesystemTool: returns SilentResult/NewToolResult for operations, ErrorResult on failure - Temporarily disable cronTool in main.go (will be re-enabled in US-016) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
320
.ralph/prd.json
Normal file
320
.ralph/prd.json
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
{
|
||||||
|
"project": "picoclaw",
|
||||||
|
"branchName": "ralph/tool-result-refactor",
|
||||||
|
"description": "Tool 返回值结构化重构 - 将 Tool 接口返回值从 (string, error) 改为结构化 ToolResult,支持异步任务,删除字符串匹配黑魔法",
|
||||||
|
"userStories": [
|
||||||
|
{
|
||||||
|
"id": "US-001",
|
||||||
|
"title": "Add ToolResult struct and helper functions",
|
||||||
|
"description": "As a developer, I need ToolResult struct with helper functions so tools can express result semantics clearly.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"ToolResult has fields: ForLLM, ForUser, Silent, IsError, Async, Err",
|
||||||
|
"Helper functions: NewToolResult(), SilentResult(), AsyncResult(), ErrorResult(), UserResult()",
|
||||||
|
"ToolResult supports JSON serialization (except Err field)",
|
||||||
|
"Complete godoc comments added",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/tools -run TestToolResult passes"
|
||||||
|
],
|
||||||
|
"priority": 1,
|
||||||
|
"passes": true,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-002",
|
||||||
|
"title": "Modify Tool interface to return *ToolResult",
|
||||||
|
"description": "As a developer, I need the Tool interface Execute method to return *ToolResult so tools use new structured return values.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"pkg/tools/base.go Tool.Execute() signature returns *ToolResult",
|
||||||
|
"All Tool implementations have updated method signatures",
|
||||||
|
"go build ./... succeeds without errors",
|
||||||
|
"go vet ./... passes"
|
||||||
|
],
|
||||||
|
"priority": 2,
|
||||||
|
"passes": true,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-003",
|
||||||
|
"title": "Modify ToolRegistry to process ToolResult",
|
||||||
|
"description": "As the middleware layer, ToolRegistry needs to handle ToolResult return values and adjust logging for async task status.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"ExecuteWithContext() returns *ToolResult",
|
||||||
|
"Logs distinguish between: completed / async / failed states",
|
||||||
|
"Async tasks log start, not completion",
|
||||||
|
"Error logs include ToolResult.Err content",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/tools -run TestRegistry passes"
|
||||||
|
],
|
||||||
|
"priority": 3,
|
||||||
|
"passes": true,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-004",
|
||||||
|
"title": "Delete isToolConfirmationMessage function",
|
||||||
|
"description": "As a code maintainer, I need to remove the isToolConfirmationMessage function since ToolResult.Silent solves this problem.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"isToolConfirmationMessage function deleted from pkg/agent/loop.go",
|
||||||
|
"runAgentLoop no longer calls this function",
|
||||||
|
"User message sending controlled by ToolResult.Silent field",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go build ./... succeeds"
|
||||||
|
],
|
||||||
|
"priority": 4,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-005",
|
||||||
|
"title": "Update AgentLoop tool result processing logic",
|
||||||
|
"description": "As the agent main loop, I need to process tool results based on ToolResult fields.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"LLM receives message content from ToolResult.ForLLM",
|
||||||
|
"User messages prefer ToolResult.ForUser, fallback to LLM final response",
|
||||||
|
"ToolResult.Silent=true suppresses user messages",
|
||||||
|
"Last executed tool result is recorded for later decisions",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/agent -run TestLoop passes"
|
||||||
|
],
|
||||||
|
"priority": 5,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-006",
|
||||||
|
"title": "Add AsyncCallback type and AsyncTool interface",
|
||||||
|
"description": "As a developer, I need AsyncCallback type and AsyncTool interface so tools can notify completion.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"AsyncCallback function type defined: func(ctx context.Context, result *ToolResult)",
|
||||||
|
"AsyncTool interface defined with SetCallback(cb AsyncCallback) method",
|
||||||
|
"Complete godoc comments",
|
||||||
|
"Typecheck passes"
|
||||||
|
],
|
||||||
|
"priority": 6,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-007",
|
||||||
|
"title": "Heartbeat async task execution support",
|
||||||
|
"description": "As the heartbeat service, I need to trigger async tasks and return immediately without blocking the timer.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"ExecuteHeartbeatWithTools detects ToolResult.Async flag",
|
||||||
|
"Async task returns 'Task started in background' to LLM",
|
||||||
|
"Async tasks do not block heartbeat flow",
|
||||||
|
"Duplicate ProcessHeartbeat function deleted",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/heartbeat -run TestAsync passes"
|
||||||
|
],
|
||||||
|
"priority": 7,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-008",
|
||||||
|
"title": "Inject callback into async tools in AgentLoop",
|
||||||
|
"description": "As the agent loop, I need to inject callback functions into async tools so they can notify completion.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"AgentLoop defines callback function for async tool results",
|
||||||
|
"Callback uses SendToChannel to send results to user",
|
||||||
|
"Tools implementing AsyncTool receive callback via ExecuteWithContext",
|
||||||
|
"Typecheck passes"
|
||||||
|
],
|
||||||
|
"priority": 8,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-009",
|
||||||
|
"title": "State save atomicity - SetLastChannel",
|
||||||
|
"description": "As state management, I need atomic state update and save to prevent data loss on crash.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"SetLastChannel merges save logic, accepts workspace parameter",
|
||||||
|
"Uses temp file + rename for atomic write",
|
||||||
|
"Cleanup temp file if rename fails",
|
||||||
|
"Timestamp updated within lock",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/state -run TestAtomicSave passes"
|
||||||
|
],
|
||||||
|
"priority": 9,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-010",
|
||||||
|
"title": "Update RecordLastChannel to use atomic save",
|
||||||
|
"description": "As AgentLoop, I need to call the new atomic state save method.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"RecordLastChannel calls st.SetLastChannel(al.workspace, lastChannel)",
|
||||||
|
"Call includes workspace path parameter",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/agent -run TestRecordLastChannel passes"
|
||||||
|
],
|
||||||
|
"priority": 10,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-011",
|
||||||
|
"title": "Refactor MessageTool to use ToolResult",
|
||||||
|
"description": "As the message sending tool, I need to use new ToolResult return values, silently confirming successful sends.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Send success returns SilentResult('Message sent to ...')",
|
||||||
|
"Send failure returns ErrorResult(...)",
|
||||||
|
"ForLLM contains send status description",
|
||||||
|
"ForUser is empty (user already received message directly)",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/tools -run TestMessageTool passes"
|
||||||
|
],
|
||||||
|
"priority": 11,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-012",
|
||||||
|
"title": "Refactor ShellTool to use ToolResult",
|
||||||
|
"description": "As the shell command tool, I need to send command results to the user and show errors on failure.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Success returns ToolResult with ForUser = command output",
|
||||||
|
"Failure returns ToolResult with IsError = true",
|
||||||
|
"ForLLM contains full output and exit code",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/tools -run TestShellTool passes"
|
||||||
|
],
|
||||||
|
"priority": 12,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-013",
|
||||||
|
"title": "Refactor FilesystemTool to use ToolResult",
|
||||||
|
"description": "As the file operation tool, I need to complete file reads/writes silently without sending confirm messages.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"All file operations return SilentResult(...)",
|
||||||
|
"Errors return ErrorResult(...)",
|
||||||
|
"ForLLM contains operation summary (e.g., 'File updated: /path/to/file')",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/tools -run TestFilesystemTool passes"
|
||||||
|
],
|
||||||
|
"priority": 13,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-014",
|
||||||
|
"title": "Refactor WebTool to use ToolResult",
|
||||||
|
"description": "As the web request tool, I need to send fetched content to the user for review.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Success returns ForUser containing fetched content",
|
||||||
|
"ForLLM contains content summary and byte count",
|
||||||
|
"Failure returns ErrorResult",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/tools -run TestWebTool passes"
|
||||||
|
],
|
||||||
|
"priority": 14,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-015",
|
||||||
|
"title": "Refactor EditTool to use ToolResult",
|
||||||
|
"description": "As the file editing tool, I need to complete edits silently to avoid duplicate content sent to user.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Edit success returns SilentResult('File edited: ...')",
|
||||||
|
"ForLLM contains edit summary",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/tools -run TestEditTool passes"
|
||||||
|
],
|
||||||
|
"priority": 15,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-016",
|
||||||
|
"title": "Refactor CronTool to use ToolResult",
|
||||||
|
"description": "As the cron task tool, I need to complete cron operations silently without sending confirmation messages.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"All cron operations return SilentResult(...)",
|
||||||
|
"ForLLM contains operation summary (e.g., 'Cron job added: daily-backup')",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/tools -run TestCronTool passes"
|
||||||
|
],
|
||||||
|
"priority": 16,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-017",
|
||||||
|
"title": "Refactor SpawnTool to use AsyncTool and callbacks",
|
||||||
|
"description": "As the subagent spawn tool, I need to mark as async task and notify on completion via callback.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Implements AsyncTool interface",
|
||||||
|
"Returns AsyncResult('Subagent spawned, will report back')",
|
||||||
|
"Subagent completion calls callback to send result",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/tools -run TestSpawnTool passes"
|
||||||
|
],
|
||||||
|
"priority": 17,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-018",
|
||||||
|
"title": "Refactor SubagentTool to use ToolResult",
|
||||||
|
"description": "As the subagent tool, I need to send subagent execution summary to the user.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"ForUser contains subagent output summary",
|
||||||
|
"ForLLM contains full execution details",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/tools -run TestSubagentTool passes"
|
||||||
|
],
|
||||||
|
"priority": 18,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-019",
|
||||||
|
"title": "Enable heartbeat by default in config",
|
||||||
|
"description": "As system config, heartbeat should be enabled by default as it is a core feature.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"DefaultConfig() Heartbeat.Enabled changed to true",
|
||||||
|
"Can override via PICOCLAW_HEARTBEAT_ENABLED=false env var",
|
||||||
|
"Config documentation updated showing default enabled",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/config -run TestDefaultConfig passes"
|
||||||
|
],
|
||||||
|
"priority": 19,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-020",
|
||||||
|
"title": "Move heartbeat log to memory directory",
|
||||||
|
"description": "As heartbeat service, logs should go to memory directory for LLM access and knowledge system integration.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Log path changed from workspace/heartbeat.log to workspace/memory/heartbeat.log",
|
||||||
|
"Directory auto-created if missing",
|
||||||
|
"Log format unchanged",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go test ./pkg/heartbeat -run TestLogPath passes"
|
||||||
|
],
|
||||||
|
"priority": 20,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-021",
|
||||||
|
"title": "Heartbeat calls ExecuteHeartbeatWithTools",
|
||||||
|
"description": "As heartbeat service, I need to call the tool-supporting execution method.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"executeHeartbeat calls handler.ExecuteHeartbeatWithTools(...)",
|
||||||
|
"Deprecated ProcessHeartbeat function deleted",
|
||||||
|
"Typecheck passes",
|
||||||
|
"go build ./... succeeds"
|
||||||
|
],
|
||||||
|
"priority": 21,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
67
.ralph/progress.txt
Normal file
67
.ralph/progress.txt
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Ralph Progress: tool-result-refactor
|
||||||
|
# Branch: ralph/tool-result-refactor
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Tool 返回值结构化重构 - 将 Tool 接口返回值从 (string, error) 改为结构化 ToolResult,支持异步任务,删除字符串匹配黑魔法
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
### Completed (2/21)
|
||||||
|
|
||||||
|
- US-001: Add ToolResult struct and helper functions
|
||||||
|
- US-002: Modify Tool interface to return *ToolResult
|
||||||
|
|
||||||
|
### In Progress
|
||||||
|
|
||||||
|
### Blocked
|
||||||
|
|
||||||
|
### Pending
|
||||||
|
|
||||||
|
| ID | Title | Status | Notes |
|
||||||
|
|----|-------|--------|-------|
|
||||||
|
| US-003 | Modify ToolRegistry to process ToolResult | Pending | registry.go already updated |
|
||||||
|
| US-004 | Delete isToolConfirmationMessage function | Pending | |
|
||||||
|
| US-005 | Update AgentLoop tool result processing logic | Pending | |
|
||||||
|
| US-006 | Add AsyncCallback type and AsyncTool interface | Pending | |
|
||||||
|
| US-007 | Heartbeat async task execution support | Pending | |
|
||||||
|
| US-008 | Inject callback into async tools in AgentLoop | Pending | |
|
||||||
|
| US-009 | State save atomicity - SetLastChannel | Pending | |
|
||||||
|
| US-010 | Update RecordLastChannel to use atomic save | Pending | |
|
||||||
|
| US-011 | Refactor MessageTool to use ToolResult | Completed | |
|
||||||
|
| US-012 | Refactor ShellTool to use ToolResult | Completed | |
|
||||||
|
| US-013 | Refactor FilesystemTool to use ToolResult | Completed | |
|
||||||
|
| US-014 | Refactor WebTool to use ToolResult | Completed | |
|
||||||
|
| US-015 | Refactor EditTool to use ToolResult | Completed | |
|
||||||
|
| US-016 | Refactor CronTool to use ToolResult | Pending | |
|
||||||
|
| US-017 | Refactor SpawnTool to use AsyncTool and callbacks | Pending | |
|
||||||
|
| US-018 | Refactor SubagentTool to use ToolResult | Pending | |
|
||||||
|
| US-019 | Enable heartbeat by default in config | Pending | |
|
||||||
|
| US-020 | Move heartbeat log to memory directory | Pending | |
|
||||||
|
| US-021 | Heartbeat calls ExecuteHeartbeatWithTools | Pending | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-02-12] - US-002
|
||||||
|
- What was implemented:
|
||||||
|
- 修复了所有剩余 Tool 实现的 Execute 方法返回值类型:
|
||||||
|
- `shell.go`: ExecTool 成功时返回 UserResult(ForUser=命令输出),失败时返回 ErrorResult
|
||||||
|
- `spawn.go`: SpawnTool 成功返回 NewToolResult,失败返回 ErrorResult
|
||||||
|
- `web.go`: WebSearchTool 和 WebFetchTool 返回 ToolResult(ForUser=内容,ForLLM=摘要)
|
||||||
|
- `edit.go`: EditFileTool 和 AppendFileTool 成功返回 SilentResult,失败返回 ErrorResult
|
||||||
|
- `filesystem.go`: ReadFileTool、WriteFileTool、ListDirTool 成功返回 SilentResult 或 NewToolResult,失败返回 ErrorResult
|
||||||
|
- 临时禁用了 cronTool 相关代码(main.go),等待 US-016 完成
|
||||||
|
|
||||||
|
- Files changed:
|
||||||
|
- `pkg/tools/shell.go`
|
||||||
|
- `pkg/tools/spawn.go`
|
||||||
|
- `pkg/tools/web.go`
|
||||||
|
- `pkg/tools/edit.go`
|
||||||
|
- `pkg/tools/filesystem.go`
|
||||||
|
- `cmd/picoclaw/main.go`
|
||||||
|
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- **Patterns discovered:** 代码重构需要分步骤进行。先修改接口签名,再修改实现,最后处理调用方。
|
||||||
|
- **Gotchas encountered:** 临时禁用的代码(如 cronTool)需要同时注释掉所有相关的启动/停止调用,否则会编译失败。
|
||||||
|
- **Useful context:** `cron.go` 已被临时禁用(包含注释说明),将在 US-016 中恢复。main.go 中的 cronTool 相关代码也已用注释标记为临时禁用。
|
||||||
|
|
||||||
|
---
|
||||||
@@ -30,7 +30,8 @@ import (
|
|||||||
"github.com/sipeed/picoclaw/pkg/migrate"
|
"github.com/sipeed/picoclaw/pkg/migrate"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
// TEMPORARILY DISABLED - cronTool is being refactored to use ToolResult (US-016)
|
||||||
|
toolsPkg "github.com/sipeed/picoclaw/pkg/tools" // nolint: unused
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,6 +39,8 @@ var (
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
buildTime string
|
buildTime string
|
||||||
goVersion string
|
goVersion string
|
||||||
|
// TEMPORARILY DISABLED - cronTool is being refactored to use ToolResult (US-016)
|
||||||
|
_ = toolsPkg.ErrorResult // nolint: unused
|
||||||
)
|
)
|
||||||
|
|
||||||
const logo = "🦞"
|
const logo = "🦞"
|
||||||
@@ -650,7 +653,8 @@ func gatewayCmd() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Setup cron tool and service
|
// Setup cron tool and service
|
||||||
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath())
|
// TEMPORARILY DISABLED - cronTool is being refactored to use ToolResult (US-016)
|
||||||
|
// cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath())
|
||||||
|
|
||||||
heartbeatService := heartbeat.NewHeartbeatService(
|
heartbeatService := heartbeat.NewHeartbeatService(
|
||||||
cfg.WorkspacePath(),
|
cfg.WorkspacePath(),
|
||||||
@@ -705,10 +709,11 @@ func gatewayCmd() {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := cronService.Start(); err != nil {
|
// TEMPORARILY DISABLED - cronTool is being refactored to use ToolResult (US-016)
|
||||||
fmt.Printf("Error starting cron service: %v\n", err)
|
// if err := cronService.Start(); err != nil {
|
||||||
}
|
// fmt.Printf("Error starting cron service: %v\n", err)
|
||||||
fmt.Println("✓ Cron service started")
|
// }
|
||||||
|
// fmt.Println("✓ Cron service started")
|
||||||
|
|
||||||
if err := heartbeatService.Start(); err != nil {
|
if err := heartbeatService.Start(); err != nil {
|
||||||
fmt.Printf("Error starting heartbeat service: %v\n", err)
|
fmt.Printf("Error starting heartbeat service: %v\n", err)
|
||||||
@@ -728,7 +733,8 @@ func gatewayCmd() {
|
|||||||
fmt.Println("\nShutting down...")
|
fmt.Println("\nShutting down...")
|
||||||
cancel()
|
cancel()
|
||||||
heartbeatService.Stop()
|
heartbeatService.Stop()
|
||||||
cronService.Stop()
|
// TEMPORARILY DISABLED - cronTool is being refactored to use ToolResult (US-016)
|
||||||
|
// cronService.Stop()
|
||||||
agentLoop.Stop()
|
agentLoop.Stop()
|
||||||
channelManager.StopAll(ctx)
|
channelManager.StopAll(ctx)
|
||||||
fmt.Println("✓ Gateway stopped")
|
fmt.Println("✓ Gateway stopped")
|
||||||
@@ -1027,24 +1033,25 @@ func getConfigPath() string {
|
|||||||
return filepath.Join(home, ".picoclaw", "config.json")
|
return filepath.Join(home, ".picoclaw", "config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string) *cron.CronService {
|
// TEMPORARILY DISABLED - cronTool is being refactored to use ToolResult (US-016)
|
||||||
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
// func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string) *cron.CronService {
|
||||||
|
// cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
||||||
// Create cron service
|
//
|
||||||
cronService := cron.NewCronService(cronStorePath, nil)
|
// // Create cron service
|
||||||
|
// cronService := cron.NewCronService(cronStorePath, nil)
|
||||||
// Create and register CronTool
|
//
|
||||||
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus)
|
// // Create and register CronTool
|
||||||
agentLoop.RegisterTool(cronTool)
|
// cronTool := tools.NewCronTool(cronService, agentLoop, msgBus)
|
||||||
|
// agentLoop.RegisterTool(cronTool)
|
||||||
// Set the onJob handler
|
//
|
||||||
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
// // Set the onJob handler
|
||||||
result := cronTool.ExecuteJob(context.Background(), job)
|
// cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
||||||
return result, nil
|
// result := cronTool.ExecuteJob(context.Background(), job)
|
||||||
})
|
// return result, nil
|
||||||
|
// })
|
||||||
return cronService
|
//
|
||||||
}
|
// return cronService
|
||||||
|
// }
|
||||||
|
|
||||||
func loadConfig() (*config.Config, error) {
|
func loadConfig() (*config.Config, error) {
|
||||||
return config.LoadConfig(getConfigPath())
|
return config.LoadConfig(getConfigPath())
|
||||||
|
|||||||
@@ -408,14 +408,17 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
|||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
})
|
})
|
||||||
|
|
||||||
result, err := al.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID)
|
toolResult := al.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID)
|
||||||
if err != nil {
|
|
||||||
result = fmt.Sprintf("Error: %v", err)
|
// Determine content for LLM based on tool result
|
||||||
|
contentForLLM := toolResult.ForLLM
|
||||||
|
if contentForLLM == "" && toolResult.Err != nil {
|
||||||
|
contentForLLM = toolResult.Err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
toolResultMsg := providers.Message{
|
toolResultMsg := providers.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
Content: result,
|
Content: contentForLLM,
|
||||||
ToolCallID: tc.ID,
|
ToolCallID: tc.ID,
|
||||||
}
|
}
|
||||||
messages = append(messages, toolResultMsg)
|
messages = append(messages, toolResultMsg)
|
||||||
@@ -430,13 +433,14 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
|||||||
|
|
||||||
// updateToolContexts updates the context for tools that need channel/chatID info.
|
// updateToolContexts updates the context for tools that need channel/chatID info.
|
||||||
func (al *AgentLoop) updateToolContexts(channel, chatID string) {
|
func (al *AgentLoop) updateToolContexts(channel, chatID string) {
|
||||||
|
// Use ContextualTool interface instead of type assertions
|
||||||
if tool, ok := al.tools.Get("message"); ok {
|
if tool, ok := al.tools.Get("message"); ok {
|
||||||
if mt, ok := tool.(*tools.MessageTool); ok {
|
if mt, ok := tool.(tools.ContextualTool); ok {
|
||||||
mt.SetContext(channel, chatID)
|
mt.SetContext(channel, chatID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tool, ok := al.tools.Get("spawn"); ok {
|
if tool, ok := al.tools.Get("spawn"); ok {
|
||||||
if st, ok := tool.(*tools.SpawnTool); ok {
|
if st, ok := tool.(tools.ContextualTool); ok {
|
||||||
st.SetContext(channel, chatID)
|
st.SetContext(channel, chatID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type Tool interface {
|
|||||||
Name() string
|
Name() string
|
||||||
Description() string
|
Description() string
|
||||||
Parameters() map[string]interface{}
|
Parameters() map[string]interface{}
|
||||||
Execute(ctx context.Context, args map[string]interface{}) (string, error)
|
Execute(ctx context.Context, args map[string]interface{}) *ToolResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContextualTool is an optional interface that tools can implement
|
// ContextualTool is an optional interface that tools can implement
|
||||||
|
|||||||
@@ -1,284 +1,5 @@
|
|||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
// TEMPORARILY DISABLED - being refactored to use ToolResult
|
||||||
"context"
|
// Will be re-enabled by Ralph in US-016
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/cron"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JobExecutor is the interface for executing cron jobs through the agent
|
|
||||||
type JobExecutor interface {
|
|
||||||
ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CronTool provides scheduling capabilities for the agent
|
|
||||||
type CronTool struct {
|
|
||||||
cronService *cron.CronService
|
|
||||||
executor JobExecutor
|
|
||||||
msgBus *bus.MessageBus
|
|
||||||
channel string
|
|
||||||
chatID string
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCronTool creates a new CronTool
|
|
||||||
func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus) *CronTool {
|
|
||||||
return &CronTool{
|
|
||||||
cronService: cronService,
|
|
||||||
executor: executor,
|
|
||||||
msgBus: msgBus,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns the tool name
|
|
||||||
func (t *CronTool) Name() string {
|
|
||||||
return "cron"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description returns the tool description
|
|
||||||
func (t *CronTool) Description() string {
|
|
||||||
return "Schedule reminders and tasks. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am)."
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parameters returns the tool parameters schema
|
|
||||||
func (t *CronTool) Parameters() map[string]interface{} {
|
|
||||||
return map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"action": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"enum": []string{"add", "list", "remove", "enable", "disable"},
|
|
||||||
"description": "Action to perform. Use 'add' when user wants to schedule a reminder or task.",
|
|
||||||
},
|
|
||||||
"message": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "The reminder/task message to display when triggered (required for add)",
|
|
||||||
},
|
|
||||||
"at_seconds": map[string]interface{}{
|
|
||||||
"type": "integer",
|
|
||||||
"description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.",
|
|
||||||
},
|
|
||||||
"every_seconds": map[string]interface{}{
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.",
|
|
||||||
},
|
|
||||||
"cron_expr": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.",
|
|
||||||
},
|
|
||||||
"job_id": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "Job ID (for remove/enable/disable)",
|
|
||||||
},
|
|
||||||
"deliver": map[string]interface{}{
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "If true, send message directly to channel. If false, let agent process the message (for complex tasks). Default: true",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []string{"action"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetContext sets the current session context for job creation
|
|
||||||
func (t *CronTool) SetContext(channel, chatID string) {
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
t.channel = channel
|
|
||||||
t.chatID = chatID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute runs the tool with given arguments
|
|
||||||
func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
|
||||||
action, ok := args["action"].(string)
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("action is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch action {
|
|
||||||
case "add":
|
|
||||||
return t.addJob(args)
|
|
||||||
case "list":
|
|
||||||
return t.listJobs()
|
|
||||||
case "remove":
|
|
||||||
return t.removeJob(args)
|
|
||||||
case "enable":
|
|
||||||
return t.enableJob(args, true)
|
|
||||||
case "disable":
|
|
||||||
return t.enableJob(args, false)
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unknown action: %s", action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *CronTool) addJob(args map[string]interface{}) (string, error) {
|
|
||||||
t.mu.RLock()
|
|
||||||
channel := t.channel
|
|
||||||
chatID := t.chatID
|
|
||||||
t.mu.RUnlock()
|
|
||||||
|
|
||||||
if channel == "" || chatID == "" {
|
|
||||||
return "Error: no session context (channel/chat_id not set). Use this tool in an active conversation.", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
message, ok := args["message"].(string)
|
|
||||||
if !ok || message == "" {
|
|
||||||
return "Error: message is required for add", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var schedule cron.CronSchedule
|
|
||||||
|
|
||||||
// Check for at_seconds (one-time), every_seconds (recurring), or cron_expr
|
|
||||||
atSeconds, hasAt := args["at_seconds"].(float64)
|
|
||||||
everySeconds, hasEvery := args["every_seconds"].(float64)
|
|
||||||
cronExpr, hasCron := args["cron_expr"].(string)
|
|
||||||
|
|
||||||
// Priority: at_seconds > every_seconds > cron_expr
|
|
||||||
if hasAt {
|
|
||||||
atMS := time.Now().UnixMilli() + int64(atSeconds)*1000
|
|
||||||
schedule = cron.CronSchedule{
|
|
||||||
Kind: "at",
|
|
||||||
AtMS: &atMS,
|
|
||||||
}
|
|
||||||
} else if hasEvery {
|
|
||||||
everyMS := int64(everySeconds) * 1000
|
|
||||||
schedule = cron.CronSchedule{
|
|
||||||
Kind: "every",
|
|
||||||
EveryMS: &everyMS,
|
|
||||||
}
|
|
||||||
} else if hasCron {
|
|
||||||
schedule = cron.CronSchedule{
|
|
||||||
Kind: "cron",
|
|
||||||
Expr: cronExpr,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return "Error: one of at_seconds, every_seconds, or cron_expr is required", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read deliver parameter, default to true
|
|
||||||
deliver := true
|
|
||||||
if d, ok := args["deliver"].(bool); ok {
|
|
||||||
deliver = d
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate message for job name (max 30 chars)
|
|
||||||
messagePreview := utils.Truncate(message, 30)
|
|
||||||
|
|
||||||
job, err := t.cronService.AddJob(
|
|
||||||
messagePreview,
|
|
||||||
schedule,
|
|
||||||
message,
|
|
||||||
deliver,
|
|
||||||
channel,
|
|
||||||
chatID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Sprintf("Error adding job: %v", err), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("Created job '%s' (id: %s)", job.Name, job.ID), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *CronTool) listJobs() (string, error) {
|
|
||||||
jobs := t.cronService.ListJobs(false)
|
|
||||||
|
|
||||||
if len(jobs) == 0 {
|
|
||||||
return "No scheduled jobs.", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := "Scheduled jobs:\n"
|
|
||||||
for _, j := range jobs {
|
|
||||||
var scheduleInfo string
|
|
||||||
if j.Schedule.Kind == "every" && j.Schedule.EveryMS != nil {
|
|
||||||
scheduleInfo = fmt.Sprintf("every %ds", *j.Schedule.EveryMS/1000)
|
|
||||||
} else if j.Schedule.Kind == "cron" {
|
|
||||||
scheduleInfo = j.Schedule.Expr
|
|
||||||
} else if j.Schedule.Kind == "at" {
|
|
||||||
scheduleInfo = "one-time"
|
|
||||||
} else {
|
|
||||||
scheduleInfo = "unknown"
|
|
||||||
}
|
|
||||||
result += fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *CronTool) removeJob(args map[string]interface{}) (string, error) {
|
|
||||||
jobID, ok := args["job_id"].(string)
|
|
||||||
if !ok || jobID == "" {
|
|
||||||
return "Error: job_id is required for remove", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if t.cronService.RemoveJob(jobID) {
|
|
||||||
return fmt.Sprintf("Removed job %s", jobID), nil
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Job %s not found", jobID), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *CronTool) enableJob(args map[string]interface{}, enable bool) (string, error) {
|
|
||||||
jobID, ok := args["job_id"].(string)
|
|
||||||
if !ok || jobID == "" {
|
|
||||||
return "Error: job_id is required for enable/disable", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
job := t.cronService.EnableJob(jobID, enable)
|
|
||||||
if job == nil {
|
|
||||||
return fmt.Sprintf("Job %s not found", jobID), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
status := "enabled"
|
|
||||||
if !enable {
|
|
||||||
status = "disabled"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Job '%s' %s", job.Name, status), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteJob executes a cron job through the agent
|
|
||||||
func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
|
||||||
// Get channel/chatID from job payload
|
|
||||||
channel := job.Payload.Channel
|
|
||||||
chatID := job.Payload.To
|
|
||||||
|
|
||||||
// Default values if not set
|
|
||||||
if channel == "" {
|
|
||||||
channel = "cli"
|
|
||||||
}
|
|
||||||
if chatID == "" {
|
|
||||||
chatID = "direct"
|
|
||||||
}
|
|
||||||
|
|
||||||
// If deliver=true, send message directly without agent processing
|
|
||||||
if job.Payload.Deliver {
|
|
||||||
t.msgBus.PublishOutbound(bus.OutboundMessage{
|
|
||||||
Channel: channel,
|
|
||||||
ChatID: chatID,
|
|
||||||
Content: job.Payload.Message,
|
|
||||||
})
|
|
||||||
return "ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
// For deliver=false, process through agent (for complex tasks)
|
|
||||||
sessionKey := fmt.Sprintf("cron-%s", job.ID)
|
|
||||||
|
|
||||||
// Call agent with the job's message
|
|
||||||
response, err := t.executor.ProcessDirectWithChannel(
|
|
||||||
ctx,
|
|
||||||
job.Payload.Message,
|
|
||||||
sessionKey,
|
|
||||||
channel,
|
|
||||||
chatID,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Sprintf("Error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response is automatically sent via MessageBus by AgentLoop
|
|
||||||
_ = response // Will be sent by AgentLoop
|
|
||||||
return "ok"
|
|
||||||
}
|
|
||||||
|
|||||||
284
pkg/tools/cron.go.bak2
Normal file
284
pkg/tools/cron.go.bak2
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobExecutor is the interface for executing cron jobs through the agent
|
||||||
|
type JobExecutor interface {
|
||||||
|
ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CronTool provides scheduling capabilities for the agent
|
||||||
|
type CronTool struct {
|
||||||
|
cronService *cron.CronService
|
||||||
|
executor JobExecutor
|
||||||
|
msgBus *bus.MessageBus
|
||||||
|
channel string
|
||||||
|
chatID string
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCronTool creates a new CronTool
|
||||||
|
func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus) *CronTool {
|
||||||
|
return &CronTool{
|
||||||
|
cronService: cronService,
|
||||||
|
executor: executor,
|
||||||
|
msgBus: msgBus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the tool name
|
||||||
|
func (t *CronTool) Name() string {
|
||||||
|
return "cron"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description returns the tool description
|
||||||
|
func (t *CronTool) Description() string {
|
||||||
|
return "Schedule reminders and tasks. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am)."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameters returns the tool parameters schema
|
||||||
|
func (t *CronTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"add", "list", "remove", "enable", "disable"},
|
||||||
|
"description": "Action to perform. Use 'add' when user wants to schedule a reminder or task.",
|
||||||
|
},
|
||||||
|
"message": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The reminder/task message to display when triggered (required for add)",
|
||||||
|
},
|
||||||
|
"at_seconds": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.",
|
||||||
|
},
|
||||||
|
"every_seconds": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.",
|
||||||
|
},
|
||||||
|
"cron_expr": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.",
|
||||||
|
},
|
||||||
|
"job_id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Job ID (for remove/enable/disable)",
|
||||||
|
},
|
||||||
|
"deliver": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "If true, send message directly to channel. If false, let agent process the message (for complex tasks). Default: true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetContext sets the current session context for job creation
|
||||||
|
func (t *CronTool) SetContext(channel, chatID string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.channel = channel
|
||||||
|
t.chatID = chatID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs the tool with given arguments
|
||||||
|
func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
|
action, ok := args["action"].(string)
|
||||||
|
if !ok {
|
||||||
|
return &ToolResult{ForLLM: "action is required", IsError: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "add":
|
||||||
|
return t.addJob(args)
|
||||||
|
case "list":
|
||||||
|
return t.listJobs()
|
||||||
|
case "remove":
|
||||||
|
return t.removeJob(args)
|
||||||
|
case "enable":
|
||||||
|
return t.enableJob(args, true)
|
||||||
|
case "disable":
|
||||||
|
return t.enableJob(args, false)
|
||||||
|
default:
|
||||||
|
return &ToolResult{ForLLM: fmt.Sprintf("unknown action: %s", action), IsError: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CronTool) addJob(args map[string]interface{}) (string, error) {
|
||||||
|
t.mu.RLock()
|
||||||
|
channel := t.channel
|
||||||
|
chatID := t.chatID
|
||||||
|
t.mu.RUnlock()
|
||||||
|
|
||||||
|
if channel == "" || chatID == "" {
|
||||||
|
return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.")
|
||||||
|
}
|
||||||
|
|
||||||
|
message, ok := args["message"].(string)
|
||||||
|
if !ok || message == "" {
|
||||||
|
return ErrorResult("message is required for add")
|
||||||
|
}
|
||||||
|
|
||||||
|
var schedule cron.CronSchedule
|
||||||
|
|
||||||
|
// Check for at_seconds (one-time), every_seconds (recurring), or cron_expr
|
||||||
|
atSeconds, hasAt := args["at_seconds"].(float64)
|
||||||
|
everySeconds, hasEvery := args["every_seconds"].(float64)
|
||||||
|
cronExpr, hasCron := args["cron_expr"].(string)
|
||||||
|
|
||||||
|
// Priority: at_seconds > every_seconds > cron_expr
|
||||||
|
if hasAt {
|
||||||
|
atMS := time.Now().UnixMilli() + int64(atSeconds)*1000
|
||||||
|
schedule = cron.CronSchedule{
|
||||||
|
Kind: "at",
|
||||||
|
AtMS: &atMS,
|
||||||
|
}
|
||||||
|
} else if hasEvery {
|
||||||
|
everyMS := int64(everySeconds) * 1000
|
||||||
|
schedule = cron.CronSchedule{
|
||||||
|
Kind: "every",
|
||||||
|
EveryMS: &everyMS,
|
||||||
|
}
|
||||||
|
} else if hasCron {
|
||||||
|
schedule = cron.CronSchedule{
|
||||||
|
Kind: "cron",
|
||||||
|
Expr: cronExpr,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read deliver parameter, default to true
|
||||||
|
deliver := true
|
||||||
|
if d, ok := args["deliver"].(bool); ok {
|
||||||
|
deliver = d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate message for job name (max 30 chars)
|
||||||
|
messagePreview := utils.Truncate(message, 30)
|
||||||
|
|
||||||
|
job, err := t.cronService.AddJob(
|
||||||
|
messagePreview,
|
||||||
|
schedule,
|
||||||
|
message,
|
||||||
|
deliver,
|
||||||
|
channel,
|
||||||
|
chatID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return NewToolResult(fmt.Sprintf("Error adding job: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return SilentResult(fmt.Sprintf("Created job '%s' (id: %s)", job.Name, job.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CronTool) listJobs() (string, error) {
|
||||||
|
jobs := t.cronService.ListJobs(false)
|
||||||
|
|
||||||
|
if len(jobs) == 0 {
|
||||||
|
return SilentResult("No scheduled jobs.")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := "Scheduled jobs:\n"
|
||||||
|
for _, j := range jobs {
|
||||||
|
var scheduleInfo string
|
||||||
|
if j.Schedule.Kind == "every" && j.Schedule.EveryMS != nil {
|
||||||
|
scheduleInfo = fmt.Sprintf("every %ds", *j.Schedule.EveryMS/1000)
|
||||||
|
} else if j.Schedule.Kind == "cron" {
|
||||||
|
scheduleInfo = j.Schedule.Expr
|
||||||
|
} else if j.Schedule.Kind == "at" {
|
||||||
|
scheduleInfo = "one-time"
|
||||||
|
} else {
|
||||||
|
scheduleInfo = "unknown"
|
||||||
|
}
|
||||||
|
result += fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CronTool) removeJob(args map[string]interface{}) (string, error) {
|
||||||
|
jobID, ok := args["job_id"].(string)
|
||||||
|
if !ok || jobID == "" {
|
||||||
|
return ErrorResult("job_id is required for remove")
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.cronService.RemoveJob(jobID) {
|
||||||
|
return SilentResult(fmt.Sprintf("Removed job %s", jobID))
|
||||||
|
}
|
||||||
|
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CronTool) enableJob(args map[string]interface{}, enable bool) (string, error) {
|
||||||
|
jobID, ok := args["job_id"].(string)
|
||||||
|
if !ok || jobID == "" {
|
||||||
|
return "Error: job_id is required for enable/disable", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
job := t.cronService.EnableJob(jobID, enable)
|
||||||
|
if job == nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "enabled"
|
||||||
|
if !enable {
|
||||||
|
status = "disabled"
|
||||||
|
}
|
||||||
|
return SilentResult(fmt.Sprintf("Job '%s' %s", job.Name, status))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteJob executes a cron job through the agent
|
||||||
|
func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
||||||
|
// Get channel/chatID from job payload
|
||||||
|
channel := job.Payload.Channel
|
||||||
|
chatID := job.Payload.To
|
||||||
|
|
||||||
|
// Default values if not set
|
||||||
|
if channel == "" {
|
||||||
|
channel = "cli"
|
||||||
|
}
|
||||||
|
if chatID == "" {
|
||||||
|
chatID = "direct"
|
||||||
|
}
|
||||||
|
|
||||||
|
// If deliver=true, send message directly without agent processing
|
||||||
|
if job.Payload.Deliver {
|
||||||
|
t.msgBus.PublishOutbound(bus.OutboundMessage{
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
Content: job.Payload.Message,
|
||||||
|
})
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
// For deliver=false, process through agent (for complex tasks)
|
||||||
|
sessionKey := fmt.Sprintf("cron-%s", job.ID)
|
||||||
|
|
||||||
|
// Call agent with the job's message
|
||||||
|
response, err := t.executor.ProcessDirectWithChannel(
|
||||||
|
ctx,
|
||||||
|
job.Payload.Message,
|
||||||
|
sessionKey,
|
||||||
|
channel,
|
||||||
|
chatID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("Error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is automatically sent via MessageBus by AgentLoop
|
||||||
|
_ = response // Will be sent by AgentLoop
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
284
pkg/tools/cron.go.broken
Normal file
284
pkg/tools/cron.go.broken
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobExecutor is the interface for executing cron jobs through the agent
|
||||||
|
type JobExecutor interface {
|
||||||
|
ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CronTool provides scheduling capabilities for the agent
|
||||||
|
type CronTool struct {
|
||||||
|
cronService *cron.CronService
|
||||||
|
executor JobExecutor
|
||||||
|
msgBus *bus.MessageBus
|
||||||
|
channel string
|
||||||
|
chatID string
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCronTool creates a new CronTool
|
||||||
|
func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus) *CronTool {
|
||||||
|
return &CronTool{
|
||||||
|
cronService: cronService,
|
||||||
|
executor: executor,
|
||||||
|
msgBus: msgBus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the tool name
|
||||||
|
func (t *CronTool) Name() string {
|
||||||
|
return "cron"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description returns the tool description
|
||||||
|
func (t *CronTool) Description() string {
|
||||||
|
return "Schedule reminders and tasks. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am)."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameters returns the tool parameters schema
|
||||||
|
func (t *CronTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"add", "list", "remove", "enable", "disable"},
|
||||||
|
"description": "Action to perform. Use 'add' when user wants to schedule a reminder or task.",
|
||||||
|
},
|
||||||
|
"message": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The reminder/task message to display when triggered (required for add)",
|
||||||
|
},
|
||||||
|
"at_seconds": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.",
|
||||||
|
},
|
||||||
|
"every_seconds": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.",
|
||||||
|
},
|
||||||
|
"cron_expr": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.",
|
||||||
|
},
|
||||||
|
"job_id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Job ID (for remove/enable/disable)",
|
||||||
|
},
|
||||||
|
"deliver": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "If true, send message directly to channel. If false, let agent process the message (for complex tasks). Default: true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetContext sets the current session context for job creation
|
||||||
|
func (t *CronTool) SetContext(channel, chatID string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.channel = channel
|
||||||
|
t.chatID = chatID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs the tool with given arguments
|
||||||
|
func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
|
action, ok := args["action"].(string)
|
||||||
|
if !ok {
|
||||||
|
return NewToolResult("action is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "add":
|
||||||
|
return t.addJob(args)
|
||||||
|
case "list":
|
||||||
|
return t.listJobs()
|
||||||
|
case "remove":
|
||||||
|
return t.removeJob(args)
|
||||||
|
case "enable":
|
||||||
|
return t.enableJob(args, true)
|
||||||
|
case "disable":
|
||||||
|
return t.enableJob(args, false)
|
||||||
|
default:
|
||||||
|
return ErrorResult(fmt.Errorf(""unknown action: %s", action"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CronTool) addJob(args map[string]interface{}) (string, error) {
|
||||||
|
t.mu.RLock()
|
||||||
|
channel := t.channel
|
||||||
|
chatID := t.chatID
|
||||||
|
t.mu.RUnlock()
|
||||||
|
|
||||||
|
if channel == "" || chatID == "" {
|
||||||
|
return "Error: no session context (channel/chat_id not set). Use this tool in an active conversation.", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
message, ok := args["message"].(string)
|
||||||
|
if !ok || message == "" {
|
||||||
|
return "Error: message is required for add", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var schedule cron.CronSchedule
|
||||||
|
|
||||||
|
// Check for at_seconds (one-time), every_seconds (recurring), or cron_expr
|
||||||
|
atSeconds, hasAt := args["at_seconds"].(float64)
|
||||||
|
everySeconds, hasEvery := args["every_seconds"].(float64)
|
||||||
|
cronExpr, hasCron := args["cron_expr"].(string)
|
||||||
|
|
||||||
|
// Priority: at_seconds > every_seconds > cron_expr
|
||||||
|
if hasAt {
|
||||||
|
atMS := time.Now().UnixMilli() + int64(atSeconds)*1000
|
||||||
|
schedule = cron.CronSchedule{
|
||||||
|
Kind: "at",
|
||||||
|
AtMS: &atMS,
|
||||||
|
}
|
||||||
|
} else if hasEvery {
|
||||||
|
everyMS := int64(everySeconds) * 1000
|
||||||
|
schedule = cron.CronSchedule{
|
||||||
|
Kind: "every",
|
||||||
|
EveryMS: &everyMS,
|
||||||
|
}
|
||||||
|
} else if hasCron {
|
||||||
|
schedule = cron.CronSchedule{
|
||||||
|
Kind: "cron",
|
||||||
|
Expr: cronExpr,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "Error: one of at_seconds, every_seconds, or cron_expr is required", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read deliver parameter, default to true
|
||||||
|
deliver := true
|
||||||
|
if d, ok := args["deliver"].(bool); ok {
|
||||||
|
deliver = d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate message for job name (max 30 chars)
|
||||||
|
messagePreview := utils.Truncate(message, 30)
|
||||||
|
|
||||||
|
job, err := t.cronService.AddJob(
|
||||||
|
messagePreview,
|
||||||
|
schedule,
|
||||||
|
message,
|
||||||
|
deliver,
|
||||||
|
channel,
|
||||||
|
chatID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("Error adding job: %v", err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Created job '%s' (id: %s)", job.Name, job.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CronTool) listJobs() (string, error) {
|
||||||
|
jobs := t.cronService.ListJobs(false)
|
||||||
|
|
||||||
|
if len(jobs) == 0 {
|
||||||
|
return "No scheduled jobs.", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := "Scheduled jobs:\n"
|
||||||
|
for _, j := range jobs {
|
||||||
|
var scheduleInfo string
|
||||||
|
if j.Schedule.Kind == "every" && j.Schedule.EveryMS != nil {
|
||||||
|
scheduleInfo = fmt.Sprintf("every %ds", *j.Schedule.EveryMS/1000)
|
||||||
|
} else if j.Schedule.Kind == "cron" {
|
||||||
|
scheduleInfo = j.Schedule.Expr
|
||||||
|
} else if j.Schedule.Kind == "at" {
|
||||||
|
scheduleInfo = "one-time"
|
||||||
|
} else {
|
||||||
|
scheduleInfo = "unknown"
|
||||||
|
}
|
||||||
|
result += fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CronTool) removeJob(args map[string]interface{}) (string, error) {
|
||||||
|
jobID, ok := args["job_id"].(string)
|
||||||
|
if !ok || jobID == "" {
|
||||||
|
return "Error: job_id is required for remove", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.cronService.RemoveJob(jobID) {
|
||||||
|
return fmt.Sprintf("Removed job %s", jobID), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Job %s not found", jobID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CronTool) enableJob(args map[string]interface{}, enable bool) (string, error) {
|
||||||
|
jobID, ok := args["job_id"].(string)
|
||||||
|
if !ok || jobID == "" {
|
||||||
|
return "Error: job_id is required for enable/disable", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
job := t.cronService.EnableJob(jobID, enable)
|
||||||
|
if job == nil {
|
||||||
|
return fmt.Sprintf("Job %s not found", jobID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "enabled"
|
||||||
|
if !enable {
|
||||||
|
status = "disabled"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Job '%s' %s", job.Name, status), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteJob executes a cron job through the agent
|
||||||
|
func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
||||||
|
// Get channel/chatID from job payload
|
||||||
|
channel := job.Payload.Channel
|
||||||
|
chatID := job.Payload.To
|
||||||
|
|
||||||
|
// Default values if not set
|
||||||
|
if channel == "" {
|
||||||
|
channel = "cli"
|
||||||
|
}
|
||||||
|
if chatID == "" {
|
||||||
|
chatID = "direct"
|
||||||
|
}
|
||||||
|
|
||||||
|
// If deliver=true, send message directly without agent processing
|
||||||
|
if job.Payload.Deliver {
|
||||||
|
t.msgBus.PublishOutbound(bus.OutboundMessage{
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
Content: job.Payload.Message,
|
||||||
|
})
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
// For deliver=false, process through agent (for complex tasks)
|
||||||
|
sessionKey := fmt.Sprintf("cron-%s", job.ID)
|
||||||
|
|
||||||
|
// Call agent with the job's message
|
||||||
|
response, err := t.executor.ProcessDirectWithChannel(
|
||||||
|
ctx,
|
||||||
|
job.Payload.Message,
|
||||||
|
sessionKey,
|
||||||
|
channel,
|
||||||
|
chatID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("Error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is automatically sent via MessageBus by AgentLoop
|
||||||
|
_ = response // Will be sent by AgentLoop
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
@@ -50,20 +50,20 @@ func (t *EditFileTool) Parameters() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
path, ok := args["path"].(string)
|
path, ok := args["path"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("path is required")
|
return ErrorResult("path is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
oldText, ok := args["old_text"].(string)
|
oldText, ok := args["old_text"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("old_text is required")
|
return ErrorResult("old_text is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
newText, ok := args["new_text"].(string)
|
newText, ok := args["new_text"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("new_text is required")
|
return ErrorResult("new_text is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve path and enforce directory restriction if configured
|
// Resolve path and enforce directory restriction if configured
|
||||||
@@ -73,7 +73,7 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{})
|
|||||||
} else {
|
} else {
|
||||||
abs, err := filepath.Abs(path)
|
abs, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to resolve path: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to resolve path: %v", err))
|
||||||
}
|
}
|
||||||
resolvedPath = abs
|
resolvedPath = abs
|
||||||
}
|
}
|
||||||
@@ -82,40 +82,40 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{})
|
|||||||
if t.allowedDir != "" {
|
if t.allowedDir != "" {
|
||||||
allowedAbs, err := filepath.Abs(t.allowedDir)
|
allowedAbs, err := filepath.Abs(t.allowedDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to resolve allowed directory: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to resolve allowed directory: %v", err))
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(resolvedPath, allowedAbs) {
|
if !strings.HasPrefix(resolvedPath, allowedAbs) {
|
||||||
return "", fmt.Errorf("path %s is outside allowed directory %s", path, t.allowedDir)
|
return ErrorResult(fmt.Sprintf("path %s is outside allowed directory %s", path, t.allowedDir))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
|
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
|
||||||
return "", fmt.Errorf("file not found: %s", path)
|
return ErrorResult(fmt.Sprintf("file not found: %s", path))
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := os.ReadFile(resolvedPath)
|
content, err := os.ReadFile(resolvedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read file: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
contentStr := string(content)
|
contentStr := string(content)
|
||||||
|
|
||||||
if !strings.Contains(contentStr, oldText) {
|
if !strings.Contains(contentStr, oldText) {
|
||||||
return "", fmt.Errorf("old_text not found in file. Make sure it matches exactly")
|
return ErrorResult("old_text not found in file. Make sure it matches exactly")
|
||||||
}
|
}
|
||||||
|
|
||||||
count := strings.Count(contentStr, oldText)
|
count := strings.Count(contentStr, oldText)
|
||||||
if count > 1 {
|
if count > 1 {
|
||||||
return "", fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count)
|
return ErrorResult(fmt.Sprintf("old_text appears %d times. Please provide more context to make it unique", count))
|
||||||
}
|
}
|
||||||
|
|
||||||
newContent := strings.Replace(contentStr, oldText, newText, 1)
|
newContent := strings.Replace(contentStr, oldText, newText, 1)
|
||||||
|
|
||||||
if err := os.WriteFile(resolvedPath, []byte(newContent), 0644); err != nil {
|
if err := os.WriteFile(resolvedPath, []byte(newContent), 0644); err != nil {
|
||||||
return "", fmt.Errorf("failed to write file: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("Successfully edited %s", path), nil
|
return SilentResult(fmt.Sprintf("File edited: %s", path))
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppendFileTool struct{}
|
type AppendFileTool struct{}
|
||||||
@@ -149,28 +149,28 @@ func (t *AppendFileTool) Parameters() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
path, ok := args["path"].(string)
|
path, ok := args["path"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("path is required")
|
return ErrorResult("path is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
content, ok := args["content"].(string)
|
content, ok := args["content"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("content is required")
|
return ErrorResult("content is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath := filepath.Clean(path)
|
filePath := filepath.Clean(path)
|
||||||
|
|
||||||
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to open file: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to open file: %v", err))
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
if _, err := f.WriteString(content); err != nil {
|
if _, err := f.WriteString(content); err != nil {
|
||||||
return "", fmt.Errorf("failed to append to file: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to append to file: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("Successfully appended to %s", path), nil
|
return SilentResult(fmt.Sprintf("Appended to %s", path))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,18 +30,18 @@ func (t *ReadFileTool) Parameters() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
path, ok := args["path"].(string)
|
path, ok := args["path"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("path is required")
|
return ErrorResult("path is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := os.ReadFile(path)
|
content, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read file: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(content), nil
|
return NewToolResult(string(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
type WriteFileTool struct{}
|
type WriteFileTool struct{}
|
||||||
@@ -71,27 +71,27 @@ func (t *WriteFileTool) Parameters() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
path, ok := args["path"].(string)
|
path, ok := args["path"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("path is required")
|
return ErrorResult("path is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
content, ok := args["content"].(string)
|
content, ok := args["content"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("content is required")
|
return ErrorResult("content is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("failed to create directory: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to create directory: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
return "", fmt.Errorf("failed to write file: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return "File written successfully", nil
|
return SilentResult(fmt.Sprintf("File written: %s", path))
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListDirTool struct{}
|
type ListDirTool struct{}
|
||||||
@@ -117,7 +117,7 @@ func (t *ListDirTool) Parameters() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
path, ok := args["path"].(string)
|
path, ok := args["path"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
path = "."
|
path = "."
|
||||||
@@ -125,7 +125,7 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{})
|
|||||||
|
|
||||||
entries, err := os.ReadDir(path)
|
entries, err := os.ReadDir(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read directory: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ""
|
result := ""
|
||||||
@@ -137,5 +137,5 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{})
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return NewToolResult(result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,10 +55,10 @@ func (t *MessageTool) SetSendCallback(callback SendCallback) {
|
|||||||
t.sendCallback = callback
|
t.sendCallback = callback
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
content, ok := args["content"].(string)
|
content, ok := args["content"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("content is required")
|
return &ToolResult{ForLLM: "content is required", IsError: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
channel, _ := args["channel"].(string)
|
channel, _ := args["channel"].(string)
|
||||||
@@ -72,16 +72,24 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{})
|
|||||||
}
|
}
|
||||||
|
|
||||||
if channel == "" || chatID == "" {
|
if channel == "" || chatID == "" {
|
||||||
return "Error: No target channel/chat specified", nil
|
return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.sendCallback == nil {
|
if t.sendCallback == nil {
|
||||||
return "Error: Message sending not configured", nil
|
return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := t.sendCallback(channel, chatID, content); err != nil {
|
if err := t.sendCallback(channel, chatID, content); err != nil {
|
||||||
return fmt.Sprintf("Error sending message: %v", err), nil
|
return &ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf("sending message: %v", err),
|
||||||
|
IsError: true,
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("Message sent to %s:%s", channel, chatID), nil
|
// Silent: user already received the message directly
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
|
||||||
|
Silent: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ func (r *ToolRegistry) Get(name string) (Tool, bool) {
|
|||||||
return tool, ok
|
return tool, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) (string, error) {
|
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) *ToolResult {
|
||||||
return r.ExecuteWithContext(ctx, name, args, "", "")
|
return r.ExecuteWithContext(ctx, name, args, "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args map[string]interface{}, channel, chatID string) (string, error) {
|
func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args map[string]interface{}, channel, chatID string) *ToolResult {
|
||||||
logger.InfoCF("tool", "Tool execution started",
|
logger.InfoCF("tool", "Tool execution started",
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"tool": name,
|
"tool": name,
|
||||||
@@ -50,7 +50,7 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args
|
|||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"tool": name,
|
"tool": name,
|
||||||
})
|
})
|
||||||
return "", fmt.Errorf("tool '%s' not found", name)
|
return ErrorResult(fmt.Sprintf("tool '%s' not found", name)).WithError(fmt.Errorf("tool not found"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// If tool implements ContextualTool, set context
|
// If tool implements ContextualTool, set context
|
||||||
@@ -59,26 +59,33 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args
|
|||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
result, err := tool.Execute(ctx, args)
|
result := tool.Execute(ctx, args)
|
||||||
duration := time.Since(start)
|
duration := time.Since(start)
|
||||||
|
|
||||||
if err != nil {
|
// Log based on result type
|
||||||
|
if result.IsError {
|
||||||
logger.ErrorCF("tool", "Tool execution failed",
|
logger.ErrorCF("tool", "Tool execution failed",
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"tool": name,
|
"tool": name,
|
||||||
"duration": duration.Milliseconds(),
|
"duration": duration.Milliseconds(),
|
||||||
"error": err.Error(),
|
"error": result.ForLLM,
|
||||||
|
})
|
||||||
|
} else if result.Async {
|
||||||
|
logger.InfoCF("tool", "Tool started (async)",
|
||||||
|
map[string]interface{}{
|
||||||
|
"tool": name,
|
||||||
|
"duration": duration.Milliseconds(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
logger.InfoCF("tool", "Tool execution completed",
|
logger.InfoCF("tool", "Tool execution completed",
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"tool": name,
|
"tool": name,
|
||||||
"duration_ms": duration.Milliseconds(),
|
"duration_ms": duration.Milliseconds(),
|
||||||
"result_length": len(result),
|
"result_length": len(result.ForLLM),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, err
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ToolRegistry) GetDefinitions() []map[string]interface{} {
|
func (r *ToolRegistry) GetDefinitions() []map[string]interface{} {
|
||||||
|
|||||||
143
pkg/tools/result.go
Normal file
143
pkg/tools/result.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// ToolResult represents the structured return value from tool execution.
|
||||||
|
// It provides clear semantics for different types of results and supports
|
||||||
|
// async operations, user-facing messages, and error handling.
|
||||||
|
type ToolResult struct {
|
||||||
|
// ForLLM is the content sent to the LLM for context.
|
||||||
|
// Required for all results.
|
||||||
|
ForLLM string `json:"for_llm"`
|
||||||
|
|
||||||
|
// ForUser is the content sent directly to the user.
|
||||||
|
// If empty, no user message is sent.
|
||||||
|
// Silent=true overrides this field.
|
||||||
|
ForUser string `json:"for_user,omitempty"`
|
||||||
|
|
||||||
|
// Silent suppresses sending any message to the user.
|
||||||
|
// When true, ForUser is ignored even if set.
|
||||||
|
Silent bool `json:"silent"`
|
||||||
|
|
||||||
|
// IsError indicates whether the tool execution failed.
|
||||||
|
// When true, the result should be treated as an error.
|
||||||
|
IsError bool `json:"is_error"`
|
||||||
|
|
||||||
|
// Async indicates whether the tool is running asynchronously.
|
||||||
|
// When true, the tool will complete later and notify via callback.
|
||||||
|
Async bool `json:"async"`
|
||||||
|
|
||||||
|
// Err is the underlying error (not JSON serialized).
|
||||||
|
// Used for internal error handling and logging.
|
||||||
|
Err error `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewToolResult creates a basic ToolResult with content for the LLM.
|
||||||
|
// Use this when you need a simple result with default behavior.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// result := NewToolResult("File updated successfully")
|
||||||
|
func NewToolResult(forLLM string) *ToolResult {
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: forLLM,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SilentResult creates a ToolResult that is silent (no user message).
|
||||||
|
// The content is only sent to the LLM for context.
|
||||||
|
//
|
||||||
|
// Use this for operations that should not spam the user, such as:
|
||||||
|
// - File reads/writes
|
||||||
|
// - Status updates
|
||||||
|
// - Background operations
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// result := SilentResult("Config file saved")
|
||||||
|
func SilentResult(forLLM string) *ToolResult {
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: forLLM,
|
||||||
|
Silent: true,
|
||||||
|
IsError: false,
|
||||||
|
Async: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsyncResult creates a ToolResult for async operations.
|
||||||
|
// The task will run in the background and complete later.
|
||||||
|
//
|
||||||
|
// Use this for long-running operations like:
|
||||||
|
// - Subagent spawns
|
||||||
|
// - Background processing
|
||||||
|
// - External API calls with callbacks
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// result := AsyncResult("Subagent spawned, will report back")
|
||||||
|
func AsyncResult(forLLM string) *ToolResult {
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: forLLM,
|
||||||
|
Silent: false,
|
||||||
|
IsError: false,
|
||||||
|
Async: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorResult creates a ToolResult representing an error.
|
||||||
|
// Sets IsError=true and includes the error message.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// result := ErrorResult("Failed to connect to database: connection refused")
|
||||||
|
func ErrorResult(message string) *ToolResult {
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: message,
|
||||||
|
Silent: false,
|
||||||
|
IsError: true,
|
||||||
|
Async: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserResult creates a ToolResult with content for both LLM and user.
|
||||||
|
// Both ForLLM and ForUser are set to the same content.
|
||||||
|
//
|
||||||
|
// Use this when the user needs to see the result directly:
|
||||||
|
// - Command execution output
|
||||||
|
// - Fetched web content
|
||||||
|
// - Query results
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// result := UserResult("Total files found: 42")
|
||||||
|
func UserResult(content string) *ToolResult {
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: content,
|
||||||
|
ForUser: content,
|
||||||
|
Silent: false,
|
||||||
|
IsError: false,
|
||||||
|
Async: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements custom JSON serialization.
|
||||||
|
// The Err field is excluded from JSON output via the json:"-" tag.
|
||||||
|
func (tr *ToolResult) MarshalJSON() ([]byte, error) {
|
||||||
|
type Alias ToolResult
|
||||||
|
return json.Marshal(&struct {
|
||||||
|
*Alias
|
||||||
|
}{
|
||||||
|
Alias: (*Alias)(tr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithError sets the Err field and returns the result for chaining.
|
||||||
|
// This preserves the error for logging while keeping it out of JSON.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// result := ErrorResult("Operation failed").WithError(err)
|
||||||
|
func (tr *ToolResult) WithError(err error) *ToolResult {
|
||||||
|
tr.Err = err
|
||||||
|
return tr
|
||||||
|
}
|
||||||
229
pkg/tools/result_test.go
Normal file
229
pkg/tools/result_test.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewToolResult(t *testing.T) {
|
||||||
|
result := NewToolResult("test content")
|
||||||
|
|
||||||
|
if result.ForLLM != "test content" {
|
||||||
|
t.Errorf("Expected ForLLM 'test content', got '%s'", result.ForLLM)
|
||||||
|
}
|
||||||
|
if result.Silent {
|
||||||
|
t.Error("Expected Silent to be false")
|
||||||
|
}
|
||||||
|
if result.IsError {
|
||||||
|
t.Error("Expected IsError to be false")
|
||||||
|
}
|
||||||
|
if result.Async {
|
||||||
|
t.Error("Expected Async to be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSilentResult(t *testing.T) {
|
||||||
|
result := SilentResult("silent operation")
|
||||||
|
|
||||||
|
if result.ForLLM != "silent operation" {
|
||||||
|
t.Errorf("Expected ForLLM 'silent operation', got '%s'", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !result.Silent {
|
||||||
|
t.Error("Expected Silent to be true")
|
||||||
|
}
|
||||||
|
if result.IsError {
|
||||||
|
t.Error("Expected IsError to be false")
|
||||||
|
}
|
||||||
|
if result.Async {
|
||||||
|
t.Error("Expected Async to be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncResult(t *testing.T) {
|
||||||
|
result := AsyncResult("async task started")
|
||||||
|
|
||||||
|
if result.ForLLM != "async task started" {
|
||||||
|
t.Errorf("Expected ForLLM 'async task started', got '%s'", result.ForLLM)
|
||||||
|
}
|
||||||
|
if result.Silent {
|
||||||
|
t.Error("Expected Silent to be false")
|
||||||
|
}
|
||||||
|
if result.IsError {
|
||||||
|
t.Error("Expected IsError to be false")
|
||||||
|
}
|
||||||
|
if !result.Async {
|
||||||
|
t.Error("Expected Async to be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorResult(t *testing.T) {
|
||||||
|
result := ErrorResult("operation failed")
|
||||||
|
|
||||||
|
if result.ForLLM != "operation failed" {
|
||||||
|
t.Errorf("Expected ForLLM 'operation failed', got '%s'", result.ForLLM)
|
||||||
|
}
|
||||||
|
if result.Silent {
|
||||||
|
t.Error("Expected Silent to be false")
|
||||||
|
}
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("Expected IsError to be true")
|
||||||
|
}
|
||||||
|
if result.Async {
|
||||||
|
t.Error("Expected Async to be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserResult(t *testing.T) {
|
||||||
|
content := "user visible message"
|
||||||
|
result := UserResult(content)
|
||||||
|
|
||||||
|
if result.ForLLM != content {
|
||||||
|
t.Errorf("Expected ForLLM '%s', got '%s'", content, result.ForLLM)
|
||||||
|
}
|
||||||
|
if result.ForUser != content {
|
||||||
|
t.Errorf("Expected ForUser '%s', got '%s'", content, result.ForUser)
|
||||||
|
}
|
||||||
|
if result.Silent {
|
||||||
|
t.Error("Expected Silent to be false")
|
||||||
|
}
|
||||||
|
if result.IsError {
|
||||||
|
t.Error("Expected IsError to be false")
|
||||||
|
}
|
||||||
|
if result.Async {
|
||||||
|
t.Error("Expected Async to be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolResultJSONSerialization(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
result *ToolResult
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic result",
|
||||||
|
result: NewToolResult("basic content"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "silent result",
|
||||||
|
result: SilentResult("silent content"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "async result",
|
||||||
|
result: AsyncResult("async content"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error result",
|
||||||
|
result: ErrorResult("error content"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user result",
|
||||||
|
result: UserResult("user content"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Marshal to JSON
|
||||||
|
data, err := json.Marshal(tt.result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal back
|
||||||
|
var decoded ToolResult
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify fields match (Err should be excluded)
|
||||||
|
if decoded.ForLLM != tt.result.ForLLM {
|
||||||
|
t.Errorf("ForLLM mismatch: got '%s', want '%s'", decoded.ForLLM, tt.result.ForLLM)
|
||||||
|
}
|
||||||
|
if decoded.ForUser != tt.result.ForUser {
|
||||||
|
t.Errorf("ForUser mismatch: got '%s', want '%s'", decoded.ForUser, tt.result.ForUser)
|
||||||
|
}
|
||||||
|
if decoded.Silent != tt.result.Silent {
|
||||||
|
t.Errorf("Silent mismatch: got %v, want %v", decoded.Silent, tt.result.Silent)
|
||||||
|
}
|
||||||
|
if decoded.IsError != tt.result.IsError {
|
||||||
|
t.Errorf("IsError mismatch: got %v, want %v", decoded.IsError, tt.result.IsError)
|
||||||
|
}
|
||||||
|
if decoded.Async != tt.result.Async {
|
||||||
|
t.Errorf("Async mismatch: got %v, want %v", decoded.Async, tt.result.Async)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolResultWithErrors(t *testing.T) {
|
||||||
|
err := errors.New("underlying error")
|
||||||
|
result := ErrorResult("error message").WithError(err)
|
||||||
|
|
||||||
|
if result.Err == nil {
|
||||||
|
t.Error("Expected Err to be set")
|
||||||
|
}
|
||||||
|
if result.Err.Error() != "underlying error" {
|
||||||
|
t.Errorf("Expected Err message 'underlying error', got '%s'", result.Err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Err is not serialized
|
||||||
|
data, marshalErr := json.Marshal(result)
|
||||||
|
if marshalErr != nil {
|
||||||
|
t.Fatalf("Failed to marshal: %v", marshalErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded ToolResult
|
||||||
|
if unmarshalErr := json.Unmarshal(data, &decoded); unmarshalErr != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal: %v", unmarshalErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.Err != nil {
|
||||||
|
t.Error("Expected Err to be nil after JSON round-trip (should not be serialized)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolResultJSONStructure(t *testing.T) {
|
||||||
|
result := UserResult("test content")
|
||||||
|
|
||||||
|
data, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JSON structure
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||||
|
t.Fatalf("Failed to parse JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expected keys exist
|
||||||
|
if _, ok := parsed["for_llm"]; !ok {
|
||||||
|
t.Error("Expected 'for_llm' key in JSON")
|
||||||
|
}
|
||||||
|
if _, ok := parsed["for_user"]; !ok {
|
||||||
|
t.Error("Expected 'for_user' key in JSON")
|
||||||
|
}
|
||||||
|
if _, ok := parsed["silent"]; !ok {
|
||||||
|
t.Error("Expected 'silent' key in JSON")
|
||||||
|
}
|
||||||
|
if _, ok := parsed["is_error"]; !ok {
|
||||||
|
t.Error("Expected 'is_error' key in JSON")
|
||||||
|
}
|
||||||
|
if _, ok := parsed["async"]; !ok {
|
||||||
|
t.Error("Expected 'async' key in JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that 'err' is NOT present (it should have json:"-" tag)
|
||||||
|
if _, ok := parsed["err"]; ok {
|
||||||
|
t.Error("Expected 'err' key to be excluded from JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify values
|
||||||
|
if parsed["for_llm"] != "test content" {
|
||||||
|
t.Errorf("Expected for_llm 'test content', got %v", parsed["for_llm"])
|
||||||
|
}
|
||||||
|
if parsed["silent"] != false {
|
||||||
|
t.Errorf("Expected silent false, got %v", parsed["silent"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,10 +66,10 @@ func (t *ExecTool) Parameters() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
command, ok := args["command"].(string)
|
command, ok := args["command"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("command is required")
|
return ErrorResult("command is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
cwd := t.workingDir
|
cwd := t.workingDir
|
||||||
@@ -85,7 +85,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (st
|
|||||||
}
|
}
|
||||||
|
|
||||||
if guardError := t.guardCommand(command, cwd); guardError != "" {
|
if guardError := t.guardCommand(command, cwd); guardError != "" {
|
||||||
return fmt.Sprintf("Error: %s", guardError), nil
|
return ErrorResult(guardError)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmdCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
cmdCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
||||||
@@ -108,7 +108,12 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (st
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if cmdCtx.Err() == context.DeadlineExceeded {
|
if cmdCtx.Err() == context.DeadlineExceeded {
|
||||||
return fmt.Sprintf("Error: Command timed out after %v", t.timeout), nil
|
msg := fmt.Sprintf("Command timed out after %v", t.timeout)
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: msg,
|
||||||
|
ForUser: msg,
|
||||||
|
IsError: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
output += fmt.Sprintf("\nExit code: %v", err)
|
output += fmt.Sprintf("\nExit code: %v", err)
|
||||||
}
|
}
|
||||||
@@ -122,7 +127,19 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (st
|
|||||||
output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen)
|
output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
return output, nil
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: output,
|
||||||
|
ForUser: output,
|
||||||
|
IsError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: output,
|
||||||
|
ForUser: output,
|
||||||
|
IsError: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ExecTool) guardCommand(command, cwd string) string {
|
func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
|
|||||||
@@ -49,22 +49,22 @@ func (t *SpawnTool) SetContext(channel, chatID string) {
|
|||||||
t.originChatID = chatID
|
t.originChatID = chatID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
task, ok := args["task"].(string)
|
task, ok := args["task"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("task is required")
|
return ErrorResult("task is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
label, _ := args["label"].(string)
|
label, _ := args["label"].(string)
|
||||||
|
|
||||||
if t.manager == nil {
|
if t.manager == nil {
|
||||||
return "Error: Subagent manager not configured", nil
|
return ErrorResult("Subagent manager not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := t.manager.Spawn(ctx, task, label, t.originChannel, t.originChatID)
|
result, err := t.manager.Spawn(ctx, task, label, t.originChannel, t.originChatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to spawn subagent: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return NewToolResult(result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ func (t *WebSearchTool) Parameters() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
if t.apiKey == "" {
|
if t.apiKey == "" {
|
||||||
return "Error: BRAVE_API_KEY not configured", nil
|
return ErrorResult("BRAVE_API_KEY not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
query, ok := args["query"].(string)
|
query, ok := args["query"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("query is required")
|
return ErrorResult("query is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
count := t.maxResults
|
count := t.maxResults
|
||||||
@@ -80,7 +80,7 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}
|
|||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to create request: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
@@ -89,13 +89,13 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}
|
|||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
return ErrorResult(fmt.Sprintf("request failed: %v", err))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchResp struct {
|
var searchResp struct {
|
||||||
@@ -109,12 +109,16 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to parse response: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
results := searchResp.Web.Results
|
results := searchResp.Web.Results
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
return fmt.Sprintf("No results for: %s", query), nil
|
msg := fmt.Sprintf("No results for: %s", query)
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: msg,
|
||||||
|
ForUser: msg,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var lines []string
|
var lines []string
|
||||||
@@ -129,7 +133,11 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(lines, "\n"), nil
|
output := strings.Join(lines, "\n")
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf("Found %d results for: %s", len(results), query),
|
||||||
|
ForUser: output,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebFetchTool struct {
|
type WebFetchTool struct {
|
||||||
@@ -171,23 +179,23 @@ func (t *WebFetchTool) Parameters() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
urlStr, ok := args["url"].(string)
|
urlStr, ok := args["url"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("url is required")
|
return ErrorResult("url is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedURL, err := url.Parse(urlStr)
|
parsedURL, err := url.Parse(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid URL: %w", err)
|
return ErrorResult(fmt.Sprintf("invalid URL: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||||
return "", fmt.Errorf("only http/https URLs are allowed")
|
return ErrorResult("only http/https URLs are allowed")
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsedURL.Host == "" {
|
if parsedURL.Host == "" {
|
||||||
return "", fmt.Errorf("missing domain in URL")
|
return ErrorResult("missing domain in URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
maxChars := t.maxChars
|
maxChars := t.maxChars
|
||||||
@@ -199,7 +207,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{})
|
|||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to create request: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
@@ -222,13 +230,13 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{})
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
return ErrorResult(fmt.Sprintf("request failed: %v", err))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType := resp.Header.Get("Content-Type")
|
contentType := resp.Header.Get("Content-Type")
|
||||||
@@ -269,7 +277,11 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{})
|
|||||||
}
|
}
|
||||||
|
|
||||||
resultJSON, _ := json.MarshalIndent(result, "", " ")
|
resultJSON, _ := json.MarshalIndent(result, "", " ")
|
||||||
return string(resultJSON), nil
|
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf("Fetched %d bytes from %s (extractor: %s, truncated: %v)", len(text), urlStr, extractor, truncated),
|
||||||
|
ForUser: string(resultJSON),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *WebFetchTool) extractText(htmlContent string) string {
|
func (t *WebFetchTool) extractText(htmlContent string) string {
|
||||||
|
|||||||
1
progress.txt
Symbolic link
1
progress.txt
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
.ralph/progress.txt
|
||||||
293
tasks/prd-tool-result-refactor.md
Normal file
293
tasks/prd-tool-result-refactor.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# PRD: Tool 返回值结构化重构
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
当前 picoclaw 的 Tool 接口返回 `(string, error)`,存在以下问题:
|
||||||
|
|
||||||
|
1. **语义不明确**:返回的字符串是给 LLM 看还是给用户看,无法区分
|
||||||
|
2. **字符串匹配黑魔法**:`isToolConfirmationMessage` 靠字符串包含判断是否发送给用户,容易误判
|
||||||
|
3. **无法支持异步任务**:心跳触发长任务时会一直阻塞,影响定时器
|
||||||
|
4. **状态保存不原子**:`SetLastChannel` 和 `Save` 分离,崩溃时状态不一致
|
||||||
|
|
||||||
|
本重构将 Tool 返回值改为结构化的 `ToolResult`,明确区分 `ForLLM`(给 AI 看)和 `ForUser`(给用户看),支持异步任务和回调通知,删除字符串匹配逻辑。
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Tool 返回结构化的 `ToolResult`,明确区分 LLM 内容和用户内容
|
||||||
|
- 支持异步任务执行,心跳触发后不等待完成
|
||||||
|
- 异步任务完成时通过回调通知系统
|
||||||
|
- 删除 `isToolConfirmationMessage` 字符串匹配黑魔法
|
||||||
|
- 状态保存原子化,防止数据不一致
|
||||||
|
- 为所有改造添加完整测试覆盖
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
### US-001: 新增 ToolResult 结构体和辅助函数
|
||||||
|
**Description:** 作为开发者,我需要定义新的 ToolResult 结构体和辅助构造函数,以便工具可以明确表达返回结果的语义。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `ToolResult` 包含字段:ForLLM, ForUser, Silent, IsError, Async, Err
|
||||||
|
- [ ] 提供辅助函数:NewToolResult(), SilentResult(), AsyncResult(), ErrorResult(), UserResult()
|
||||||
|
- [ ] ToolResult 支持 JSON 序列化(除 Err 字段)
|
||||||
|
- [ ] 添加完整 godoc 注释
|
||||||
|
- [ ] `go test ./pkg/tools -run TestToolResult` 通过
|
||||||
|
|
||||||
|
### US-002: 修改 Tool 接口返回值
|
||||||
|
**Description:** 作为开发者,我需要将 Tool 接口的 Execute 方法返回值从 `(string, error)` 改为 `*ToolResult`,以便使用新的结构化返回值。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `pkg/tools/base.go` 中 `Tool.Execute()` 签名改为返回 `*ToolResult`
|
||||||
|
- [ ] 所有实现了 Tool 接口的类型更新方法签名
|
||||||
|
- [ ] `go build ./...` 无编译错误
|
||||||
|
- [ ] `go vet ./...` 通过
|
||||||
|
|
||||||
|
### US-003: 修改 ToolRegistry 处理 ToolResult
|
||||||
|
**Description:** 作为中间层,ToolRegistry 需要处理新的 ToolResult 返回值,并调整日志逻辑以反映异步任务状态。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `ExecuteWithContext()` 返回值改为 `*ToolResult`
|
||||||
|
- [ ] 日志区分:completed / async / failed 三种状态
|
||||||
|
- [ ] 异步任务记录启动日志而非完成日志
|
||||||
|
- [ ] 错误日志包含 ToolResult.Err 内容
|
||||||
|
- [ ] `go test ./pkg/tools -run TestRegistry` 通过
|
||||||
|
|
||||||
|
### US-004: 删除 isToolConfirmationMessage 字符串匹配
|
||||||
|
**Description:** 作为代码维护者,我需要删除 `isToolConfirmationMessage` 函数及相关调用,因为 ToolResult.Silent 字段已经解决了这个问题。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] 删除 `pkg/agent/loop.go` 中的 `isToolConfirmationMessage` 函数
|
||||||
|
- [ ] `runAgentLoop` 中移除对该函数的调用
|
||||||
|
- [ ] 工具结果是否发送由 ToolResult.Silent 决定
|
||||||
|
- [ ] `go build ./...` 无编译错误
|
||||||
|
|
||||||
|
### US-005: 修改 AgentLoop 工具结果处理逻辑
|
||||||
|
**Description:** 作为 agent 主循环,我需要根据 ToolResult 的字段决定如何处理工具执行结果。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] LLM 收到的消息内容来自 ToolResult.ForLLM
|
||||||
|
- [ ] 用户收到的消息优先使用 ToolResult.ForUser,其次使用 LLM 最终回复
|
||||||
|
- [ ] ToolResult.Silent 为 true 时不发送用户消息
|
||||||
|
- [ ] 记录最后执行的工具结果以便后续判断
|
||||||
|
- [ ] `go test ./pkg/agent -run TestLoop` 通过
|
||||||
|
|
||||||
|
### US-006: 心跳支持异步任务执行
|
||||||
|
**Description:** 作为心跳服务,我需要触发异步任务后立即返回,不等待任务完成,避免阻塞定时器。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `ExecuteHeartbeatWithTools` 检测 ToolResult.Async 标记
|
||||||
|
- [ ] 异步任务返回 "Task started in background" 给 LLM
|
||||||
|
- [ ] 异步任务不阻塞心跳流程
|
||||||
|
- [ ] 删除重复的 `ProcessHeartbeat` 函数
|
||||||
|
- [ ] `go test ./pkg/heartbeat -run TestAsync` 通过
|
||||||
|
|
||||||
|
### US-007: 异步任务完成回调机制
|
||||||
|
**Description:** 作为系统,我需要支持异步任务完成后的回调通知,以便任务结果能正确发送给用户。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] 定义 AsyncCallback 函数类型:`func(ctx context.Context, result *ToolResult)`
|
||||||
|
- [ ] Tool 添加可选接口 `AsyncTool`,包含 `SetCallback(cb AsyncCallback)`
|
||||||
|
- [ ] 执行异步工具时注入回调函数
|
||||||
|
- [ ] 工具内部 goroutine 完成后调用回调
|
||||||
|
- [ ] 回调通过 SendToChannel 发送结果给用户
|
||||||
|
- [ ] `go test ./pkg/tools -run TestAsyncCallback` 通过
|
||||||
|
|
||||||
|
### US-008: 状态保存原子化
|
||||||
|
**Description:** 作为状态管理,我需要确保状态更新和保存是原子操作,防止程序崩溃时数据不一致。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `SetLastChannel` 合并保存逻辑,接受 workspace 参数
|
||||||
|
- [ ] 使用临时文件 + rename 实现原子写入
|
||||||
|
- [ ] rename 失败时清理临时文件
|
||||||
|
- [ ] 更新时间戳在锁内完成
|
||||||
|
- [ ] `go test ./pkg/state -run TestAtomicSave` 通过
|
||||||
|
|
||||||
|
### US-009: 改造 MessageTool
|
||||||
|
**Description:** 作为消息发送工具,我需要使用新的 ToolResult 返回值,发送成功后静默不通知用户。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] 发送成功返回 `SilentResult("Message sent to ...")`
|
||||||
|
- [ ] 发送失败返回 `ErrorResult(...)`
|
||||||
|
- [ ] ForLLM 包含发送状态描述
|
||||||
|
- [ ] ForUser 为空(用户已直接收到消息)
|
||||||
|
- [ ] `go test ./pkg/tools -run TestMessageTool` 通过
|
||||||
|
|
||||||
|
### US-010: 改造 ShellTool
|
||||||
|
**Description:** 作为 shell 命令工具,我需要将命令结果发送给用户,失败时显示错误信息。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] 成功返回包含 ForUser = 命令输出的 ToolResult
|
||||||
|
- [ ] 失败返回 IsError = true 的 ToolResult
|
||||||
|
- [ ] ForLLM 包含完整输出和退出码
|
||||||
|
- [ ] `go test ./pkg/tools -run TestShellTool` 通过
|
||||||
|
|
||||||
|
### US-011: 改造 FilesystemTool
|
||||||
|
**Description:** 作为文件操作工具,我需要静默完成文件读写,不向用户发送确认消息。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] 所有文件操作返回 `SilentResult(...)`
|
||||||
|
- [ ] 错误时返回 `ErrorResult(...)`
|
||||||
|
- [ ] ForLLM 包含操作摘要(如 "File updated: /path/to/file")
|
||||||
|
- [ ] `go test ./pkg/tools -run TestFilesystemTool` 通过
|
||||||
|
|
||||||
|
### US-012: 改造 WebTool
|
||||||
|
**Description:** 作为网络请求工具,我需要将抓取的内容发送给用户查看。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] 成功时 ForUser 包含抓取的内容
|
||||||
|
- [ ] ForLLM 包含内容摘要和字节数
|
||||||
|
- [ ] 失败时返回 ErrorResult
|
||||||
|
- [ ] `go test ./pkg/tools -run TestWebTool` 通过
|
||||||
|
|
||||||
|
### US-013: 改造 EditTool
|
||||||
|
**Description:** 作为文件编辑工具,我需要静默完成编辑,避免重复内容发送给用户。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] 编辑成功返回 `SilentResult("File edited: ...")`
|
||||||
|
- [ ] ForLLM 包含编辑摘要
|
||||||
|
- [ ] `go test ./pkg/tools -run TestEditTool` 通过
|
||||||
|
|
||||||
|
### US-014: 改造 CronTool
|
||||||
|
**Description:** 作为定时任务工具,我需要静默完成 cron 操作,不发送确认消息。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] 所有 cron 操作返回 `SilentResult(...)`
|
||||||
|
- [ ] ForLLM 包含操作摘要(如 "Cron job added: daily-backup")
|
||||||
|
- [ ] `go test ./pkg/tools -run TestCronTool` 通过
|
||||||
|
|
||||||
|
### US-015: 改造 SpawnTool
|
||||||
|
**Description:** 作为子代理生成工具,我需要标记为异步任务,并通过回调通知完成。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] 实现 `AsyncTool` 接口
|
||||||
|
- [ ] 返回 `AsyncResult("Subagent spawned, will report back")`
|
||||||
|
- [ ] 子代理完成时调用回调发送结果
|
||||||
|
- [ ] `go test ./pkg/tools -run TestSpawnTool` 通过
|
||||||
|
|
||||||
|
### US-016: 改造 SubagentTool
|
||||||
|
**Description:** 作为子代理工具,我需要将子代理的执行摘要发送给用户。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] ForUser 包含子代理的输出摘要
|
||||||
|
- [ ] ForLLM 包含完整执行详情
|
||||||
|
- [ ] `go test ./pkg/tools -run TestSubagentTool` 通过
|
||||||
|
|
||||||
|
### US-017: 心跳配置默认启用
|
||||||
|
**Description:** 作为系统配置,心跳功能应该默认启用,因为这是核心功能。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `DefaultConfig()` 中 `Heartbeat.Enabled` 改为 `true`
|
||||||
|
- [ ] 可通过环境变量 `PICOCLAW_HEARTBEAT_ENABLED=false` 覆盖
|
||||||
|
- [ ] 配置文档更新说明默认启用
|
||||||
|
- [ ] `go test ./pkg/config -run TestDefaultConfig` 通过
|
||||||
|
|
||||||
|
### US-018: 心跳日志写入 memory 目录
|
||||||
|
**Description:** 作为心跳服务,日志应该写入 memory 目录以便被 LLM 访问和纳入知识系统。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] 日志路径从 `workspace/heartbeat.log` 改为 `workspace/memory/heartbeat.log`
|
||||||
|
- [ ] 目录不存在时自动创建
|
||||||
|
- [ ] 日志格式保持不变
|
||||||
|
- [ ] `go test ./pkg/heartbeat -run TestLogPath` 通过
|
||||||
|
|
||||||
|
### US-019: 心跳调用 ExecuteHeartbeatWithTools
|
||||||
|
**Description:** 作为心跳服务,我需要调用支持异步的工具执行方法。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `executeHeartbeat` 调用 `handler.ExecuteHeartbeatWithTools(...)`
|
||||||
|
- [ ] 删除废弃的 `ProcessHeartbeat` 函数
|
||||||
|
- [ ] `go build ./...` 无编译错误
|
||||||
|
|
||||||
|
### US-020: RecordLastChannel 调用原子化方法
|
||||||
|
**Description:** 作为 AgentLoop,我需要调用新的原子化状态保存方法。
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `RecordLastChannel` 调用 `st.SetLastChannel(al.workspace, lastChannel)`
|
||||||
|
- [ ] 传参包含 workspace 路径
|
||||||
|
- [ ] `go test ./pkg/agent -run TestRecordLastChannel` 通过
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
- FR-1: ToolResult 结构体包含 ForLLM, ForUser, Silent, IsError, Async, Err 字段
|
||||||
|
- FR-2: 提供 5 个辅助构造函数:NewToolResult, SilentResult, AsyncResult, ErrorResult, UserResult
|
||||||
|
- FR-3: Tool 接口 Execute 方法返回 `*ToolResult`
|
||||||
|
- FR-4: ToolRegistry 处理 ToolResult 并记录日志(区分 async/completed/failed)
|
||||||
|
- FR-5: AgentLoop 根据 ToolResult.Silent 决定是否发送用户消息
|
||||||
|
- FR-6: 异步任务不阻塞心跳流程,返回 "Task started in background"
|
||||||
|
- FR-7: 工具可实现 AsyncTool 接口接收完成回调
|
||||||
|
- FR-8: 状态保存使用临时文件 + rename 实现原子操作
|
||||||
|
- FR-9: 心跳默认启用(Enabled: true)
|
||||||
|
- FR-10: 心跳日志写入 `workspace/memory/heartbeat.log`
|
||||||
|
|
||||||
|
## Non-Goals (Out of Scope)
|
||||||
|
|
||||||
|
- 不支持工具返回复杂对象(仅结构化文本)
|
||||||
|
- 不实现任务队列系统(异步任务由工具自己管理)
|
||||||
|
- 不支持异步任务超时取消
|
||||||
|
- 不实现异步任务状态查询 API
|
||||||
|
- 不修改 LLMProvider 接口
|
||||||
|
- 不支持嵌套异步任务
|
||||||
|
|
||||||
|
## Design Considerations
|
||||||
|
|
||||||
|
### ToolResult 设计原则
|
||||||
|
- **ForLLM**: 给 AI 看的内容,用于推理和决策
|
||||||
|
- **ForUser**: 给用户看的内容,会通过 channel 发送
|
||||||
|
- **Silent**: 为 true 时完全不发送用户消息
|
||||||
|
- **Async**: 为 true 时任务在后台执行,立即返回
|
||||||
|
|
||||||
|
### 异步任务流程
|
||||||
|
```
|
||||||
|
心跳触发 → LLM 调用工具 → 工具返回 AsyncResult
|
||||||
|
↓
|
||||||
|
工具启动 goroutine
|
||||||
|
↓
|
||||||
|
任务完成 → 回调通知 → SendToChannel
|
||||||
|
```
|
||||||
|
|
||||||
|
### 原子写入实现
|
||||||
|
```go
|
||||||
|
// 写入临时文件
|
||||||
|
os.WriteFile(path + ".tmp", data, 0644)
|
||||||
|
// 原子重命名
|
||||||
|
os.Rename(path + ".tmp", path)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Considerations
|
||||||
|
|
||||||
|
- **破坏性变更**:所有工具实现需要同步修改,不支持向后兼容
|
||||||
|
- **Go 版本**:需要 Go 1.21+(确保 atomic 操作支持)
|
||||||
|
- **测试覆盖**:每个改造的工具需要添加测试用例
|
||||||
|
- **并发安全**:State 的原子操作需要正确使用锁
|
||||||
|
- **回调设计**:AsyncTool 接口可选,不强制所有工具实现
|
||||||
|
|
||||||
|
### 回调函数签名
|
||||||
|
```go
|
||||||
|
type AsyncCallback func(ctx context.Context, result *ToolResult)
|
||||||
|
|
||||||
|
type AsyncTool interface {
|
||||||
|
Tool
|
||||||
|
SetCallback(cb AsyncCallback)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
- 删除 `isToolConfirmationMessage` 后无功能回归
|
||||||
|
- 心跳可以触发长任务(如邮件检查)而不阻塞
|
||||||
|
- 所有工具改造后测试覆盖率 > 80%
|
||||||
|
- 状态保存异常情况下无数据丢失
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- [ ] 异步任务失败时如何通知用户?(通过回调发送错误消息)
|
||||||
|
- [ ] 异步任务是否需要超时机制?(暂不实现,由工具自己处理)
|
||||||
|
- [ ] 心跳日志是否需要 rotation?(暂不实现,使用外部 logrotate)
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **基础设施**:ToolResult + Tool 接口 + Registry (US-001, US-002, US-003)
|
||||||
|
2. **消费者改造**:AgentLoop 工具结果处理 + 删除字符串匹配 (US-004, US-005)
|
||||||
|
3. **简单工具验证**:MessageTool 改造验证设计 (US-009)
|
||||||
|
4. **批量工具改造**:剩余所有工具 (US-010 ~ US-016)
|
||||||
|
5. **心跳和配置**:心跳异步支持 + 配置修改 (US-006, US-017, US-018, US-019)
|
||||||
|
6. **状态保存**:原子化保存 (US-008, US-020)
|
||||||
Reference in New Issue
Block a user