diff --git a/.gitignore b/.gitignore index 7163f5f..6ba4117 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ coverage.html # Ralph workspace ralph/ -.ralph/ \ No newline at end of file +.ralph/ +tasks/ \ No newline at end of file diff --git a/Makefile b/Makefile index c9af7d5..2defcce 100644 --- a/Makefile +++ b/Makefile @@ -8,9 +8,10 @@ MAIN_GO=$(CMD_DIR)/main.go # Version VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") BUILD_TIME=$(shell date +%FT%T%z) GO_VERSION=$(shell $(GO) version | awk '{print $$3}') -LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)" +LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)" # Go variables GO?=go diff --git a/README.ja.md b/README.ja.md index 311ce30..48105ce 100644 --- a/README.ja.md +++ b/README.ja.md @@ -186,7 +186,7 @@ picoclaw onboard "providers": { "openrouter": { "api_key": "xxx", - "api_base": "https://open.bigmodel.cn/api/paas/v4" + "api_base": "https://openrouter.ai/api/v1" } }, "tools": { @@ -223,12 +223,14 @@ picoclaw agent -m "What is 2+2?" ## 💬 チャットアプリ -Telegram で PicoClaw と会話できます +Telegram、Discord、QQ、DingTalk で PicoClaw と会話できます | チャネル | セットアップ | |---------|------------| | **Telegram** | 簡単(トークンのみ) | | **Discord** | 簡単(Bot トークン + Intents) | +| **QQ** | 簡単(AppID + AppSecret) | +| **DingTalk** | 普通(アプリ認証情報) |
Telegram(推奨) @@ -307,6 +309,73 @@ picoclaw gateway
+
+QQ + +**1. Bot を作成** + +- [QQ オープンプラットフォーム](https://connect.qq.com/) にアクセス +- アプリケーションを作成 → **AppID** と **AppSecret** を取得 + +**2. 設定** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> `allow_from` を空にすると全ユーザーを許可、QQ番号を指定してアクセス制限可能。 + +**3. 起動** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Bot を作成** + +- [オープンプラットフォーム](https://open.dingtalk.com/) にアクセス +- 内部アプリを作成 +- Client ID と Client Secret をコピー + +**2. 設定** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> `allow_from` を空にすると全ユーザーを許可、ユーザーIDを指定してアクセス制限可能。 + +**3. 起動** + +```bash +picoclaw gateway +``` + +
+ ## ⚙️ 設定 設定ファイル: `~/.picoclaw/config.json` @@ -330,6 +399,98 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw └── USER.md # ユーザー設定 ``` +### 🔒 セキュリティサンドボックス + +PicoClaw はデフォルトでサンドボックス環境で実行されます。エージェントは設定されたワークスペース内のファイルにのみアクセスし、コマンドを実行できます。 + +#### デフォルト設定 + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| オプション | デフォルト | 説明 | +|-----------|-----------|------| +| `workspace` | `~/.picoclaw/workspace` | エージェントの作業ディレクトリ | +| `restrict_to_workspace` | `true` | ファイル/コマンドアクセスをワークスペースに制限 | + +#### 保護対象ツール + +`restrict_to_workspace: true` の場合、以下のツールがサンドボックス化されます: + +| ツール | 機能 | 制限 | +|-------|------|------| +| `read_file` | ファイル読み込み | ワークスペース内のファイルのみ | +| `write_file` | ファイル書き込み | ワークスペース内のファイルのみ | +| `list_dir` | ディレクトリ一覧 | ワークスペース内のディレクトリのみ | +| `edit_file` | ファイル編集 | ワークスペース内のファイルのみ | +| `append_file` | ファイル追記 | ワークスペース内のファイルのみ | +| `exec` | コマンド実行 | コマンドパスはワークスペース内である必要あり | + +#### exec ツールの追加保護 + +`restrict_to_workspace: false` でも、`exec` ツールは以下の危険なコマンドをブロックします: + +- `rm -rf`, `del /f`, `rmdir /s` — 一括削除 +- `format`, `mkfs`, `diskpart` — ディスクフォーマット +- `dd if=` — ディスクイメージング +- `/dev/sd[a-z]` への書き込み — 直接ディスク書き込み +- `shutdown`, `reboot`, `poweroff` — システムシャットダウン +- フォークボム `:(){ :|:& };:` + +#### エラー例 + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### 制限の無効化(セキュリティリスク) + +エージェントにワークスペース外のパスへのアクセスが必要な場合: + +**方法1: 設定ファイル** +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**方法2: 環境変数** +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **警告**: この制限を無効にすると、エージェントはシステム上の任意のパスにアクセスできるようになります。制御された環境でのみ慎重に使用してください。 + +#### セキュリティ境界の一貫性 + +`restrict_to_workspace` 設定は、すべての実行パスで一貫して適用されます: + +| 実行パス | セキュリティ境界 | +|---------|-----------------| +| メインエージェント | `restrict_to_workspace` ✅ | +| サブエージェント / Spawn | 同じ制限を継承 ✅ | +| ハートビートタスク | 同じ制限を継承 ✅ | + +すべてのパスで同じワークスペース制限が適用されます — サブエージェントやスケジュールタスクを通じてセキュリティ境界をバイパスする方法はありません。 + ### ハートビート(定期タスク) PicoClaw は自動的に定期タスクを実行できます。ワークスペースに `HEARTBEAT.md` ファイルを作成します: diff --git a/README.md b/README.md index ec7cd91..2ba7088 100644 --- a/README.md +++ b/README.md @@ -425,6 +425,98 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa └── USER.md # User preferences ``` +### 🔒 Security Sandbox + +PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. + +#### Default Configuration + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent | +| `restrict_to_workspace` | `true` | Restrict file/command access to workspace | + +#### Protected Tools + +When `restrict_to_workspace: true`, the following tools are sandboxed: + +| Tool | Function | Restriction | +|------|----------|-------------| +| `read_file` | Read files | Only files within workspace | +| `write_file` | Write files | Only files within workspace | +| `list_dir` | List directories | Only directories within workspace | +| `edit_file` | Edit files | Only files within workspace | +| `append_file` | Append to files | Only files within workspace | +| `exec` | Execute commands | Command paths must be within workspace | + +#### Additional Exec Protection + +Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands: + +- `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion +- `format`, `mkfs`, `diskpart` — Disk formatting +- `dd if=` — Disk imaging +- Writing to `/dev/sd[a-z]` — Direct disk writes +- `shutdown`, `reboot`, `poweroff` — System shutdown +- Fork bomb `:(){ :|:& };:` + +#### Error Examples + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Disabling Restrictions (Security Risk) + +If you need the agent to access paths outside the workspace: + +**Method 1: Config file** +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Method 2: Environment variable** +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Warning**: Disabling this restriction allows the agent to access any path on your system. Use with caution in controlled environments only. + +#### Security Boundary Consistency + +The `restrict_to_workspace` setting applies consistently across all execution paths: + +| Execution Path | Security Boundary | +|----------------|-------------------| +| Main Agent | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Inherits same restriction ✅ | +| Heartbeat tasks | Inherits same restriction ✅ | + +All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks. + ### Heartbeat (Periodic Tasks) PicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace: diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 8c00110..21246cf 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -36,21 +36,40 @@ import ( var ( version = "dev" + gitCommit string buildTime string goVersion string ) const logo = "🦞" -func printVersion() { - fmt.Printf("%s picoclaw %s\n", logo, version) - if buildTime != "" { - fmt.Printf(" Build: %s\n", buildTime) +// formatVersion returns the version string with optional git commit +func formatVersion() string { + v := version + if gitCommit != "" { + v += fmt.Sprintf(" (git: %s)", gitCommit) } - goVer := goVersion + return v +} + +// formatBuildInfo returns build time and go version info +func formatBuildInfo() (build string, goVer string) { + if buildTime != "" { + build = buildTime + } + goVer = goVersion if goVer == "" { goVer = runtime.Version() } + return +} + +func printVersion() { + fmt.Printf("%s picoclaw %s\n", logo, formatVersion()) + build, goVer := formatBuildInfo() + if build != "" { + fmt.Printf(" Build: %s\n", build) + } if goVer != "" { fmt.Printf(" Go: %s\n", goVer) } @@ -760,7 +779,13 @@ func statusCmd() { configPath := getConfigPath() - fmt.Printf("%s picoclaw Status\n\n", logo) + fmt.Printf("%s picoclaw Status\n", logo) + fmt.Printf("Version: %s\n", formatVersion()) + build, _ := formatBuildInfo() + if build != "" { + fmt.Printf("Build: %s\n", build) + } + fmt.Println() if _, err := os.Stat(configPath); err == nil { fmt.Println("Config:", configPath, "✓") @@ -1281,53 +1306,6 @@ func cronEnableCmd(storePath string, disable bool) { } } -func skillsCmd() { - if len(os.Args) < 3 { - skillsHelp() - return - } - - subcommand := os.Args[2] - - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - os.Exit(1) - } - - workspace := cfg.WorkspacePath() - installer := skills.NewSkillInstaller(workspace) - // 获取全局配置目录和内置 skills 目录 - globalDir := filepath.Dir(getConfigPath()) - globalSkillsDir := filepath.Join(globalDir, "skills") - builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") - skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir) - - switch subcommand { - case "list": - skillsListCmd(skillsLoader) - case "install": - skillsInstallCmd(installer) - case "remove", "uninstall": - if len(os.Args) < 4 { - fmt.Println("Usage: picoclaw skills remove ") - return - } - skillsRemoveCmd(installer, os.Args[3]) - case "search": - skillsSearchCmd(installer) - case "show": - if len(os.Args) < 4 { - fmt.Println("Usage: picoclaw skills show ") - return - } - skillsShowCmd(skillsLoader, os.Args[3]) - default: - fmt.Printf("Unknown skills command: %s\n", subcommand) - skillsHelp() - } -} - func skillsHelp() { fmt.Println("\nSkills commands:") fmt.Println(" list List installed skills") diff --git a/config/config.openrouter.json b/config/config.openrouter.json deleted file mode 100644 index 4aca883..0000000 --- a/config/config.openrouter.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "arcee-ai/trinity-large-preview:free", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "channels": { - "telegram": { - "enabled": false, - "token": "YOUR_TELEGRAM_BOT_TOKEN", - "allow_from": [ - "YOUR_USER_ID" - ] - }, - "discord": { - "enabled": true, - "token": "YOUR_DISCORD_BOT_TOKEN", - "allow_from": [] - }, - "maixcam": { - "enabled": false, - "host": "0.0.0.0", - "port": 18790, - "allow_from": [] - }, - "whatsapp": { - "enabled": false, - "bridge_url": "ws://localhost:3001", - "allow_from": [] - }, - "feishu": { - "enabled": false, - "app_id": "", - "app_secret": "", - "encrypt_key": "", - "verification_token": "", - "allow_from": [] - } - }, - "providers": { - "anthropic": { - "api_key": "", - "api_base": "" - }, - "openai": { - "api_key": "", - "api_base": "" - }, - "openrouter": { - "api_key": "sk-or-v1-xxx", - "api_base": "" - }, - "groq": { - "api_key": "gsk_xxx", - "api_base": "" - }, - "zhipu": { - "api_key": "YOUR_ZHIPU_API_KEY", - "api_base": "" - }, - "gemini": { - "api_key": "", - "api_base": "" - }, - "vllm": { - "api_key": "", - "api_base": "" - } - }, - "tools": { - "web": { - "search": { - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - } - } - }, - "gateway": { - "host": "0.0.0.0", - "port": 18790 - } -} \ No newline at end of file diff --git a/tasks/prd-tool-result-refactor.md b/tasks/prd-tool-result-refactor.md deleted file mode 100644 index c0e984d..0000000 --- a/tasks/prd-tool-result-refactor.md +++ /dev/null @@ -1,293 +0,0 @@ -# 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)