diff --git a/README.md b/README.md index 12c270c..06fb71b 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -# ClawGo 🦞 +# ClawGo **面向生产的 Go 原生 Agent Runtime。** ClawGo 不是“又一个聊天壳子”,而是一套可长期运行、可观测、可恢复、可编排的 Agent 运行时。 -- 👀 **可观测**:Agent 拓扑、内部流、任务审计、EKG 一体化可见 -- 🔁 **可恢复**:运行态落盘,重启后任务可恢复,watchdog 按进展续时 -- 🧩 **可编排**:`main agent -> subagent -> main`,支持本地与远端 node 分支 -- ⚙️ **可工程化**:`config.json`、`AGENT.md`、热更新、WebUI、声明式 registry +- `main agent` 负责入口、路由、派发、汇总 +- `subagent runtime` 负责本地或远端分支执行 +- `runtime store` 持久化 run、event、thread、message、memory +- WebUI 负责检查、状态展示和账号管理,不负责运行时配置写入 [English](./README_EN.md) @@ -21,74 +21,59 @@ ClawGo 不是“又一个聊天壳子”,而是一套可长期运行、可观 ClawGo 更关注真正的运行时能力: -- `main agent` 负责入口、路由、派发、汇总 -- `subagent` 负责编码、测试、产品、文档等具体执行 -- `node branch` 把远端节点挂成受控 agent 分支 -- `runtime store` 持久化 run、event、thread、message、memory +- 多 Agent 拓扑和内部协作流 +- 可恢复的 subagent run +- 本地主控加远端 node 分支 +- 配置、审计、日志、记忆的工程化闭环 一句话: -> **ClawGo = Agent Runtime,而不只是 Agent Chat。** +> **ClawGo 是 Agent Runtime,而不只是 Agent Chat。** -## 核心亮点 ✨ +## 核心能力 -### 1. 多 Agent 拓扑可视化 +### 1. 多 Agent 拓扑 - 统一展示 `main / subagents / remote branches` -- 内部流与用户主对话分离 -- 子 agent 协作过程可观测,但不污染用户通道 +- 内部协作流与用户对话分离 +- 子代理执行过程可追踪,但不会污染用户主通道 -### 2. 任务可恢复,不是一挂全没 +### 2. 可恢复执行 - `subagent_runs.jsonl` - `subagent_events.jsonl` - `threads.jsonl` - `agent_messages.jsonl` -- 重启后可恢复运行中的任务 +- 重启后可恢复进行中的 subagent run -### 3. watchdog 按进展续时 - -- 系统超时统一走全局 watchdog -- 还在推进的任务不会因为固定墙钟超时被直接杀掉 -- 无进展时才超时,行为更接近真实工程执行 - -### 4. 配置工程化,而不是 prompt 堆砌 +### 3. 工程化配置 - `config.json` 管理 agent registry - `system_prompt_file -> AGENT.md` -- WebUI 可编辑、热更新、查看运行态 +- WebUI 可查看状态、节点、账号与运行信息 +- 运行时配置修改回到文件,不通过 WebUI 写入 -### 5. Spec Coding(规范驱动开发) - -- 明确需要编码且属于非 trivial 的任务可走 `spec.md -> tasks.md -> checklist.md` -- 小修小补、轻微代码调整、单点改动默认不启用这套流程 -- `spec.md` 负责范围、决策、权衡 -- `tasks.md` 负责任务拆解和进度更新 -- `checklist.md` 负责最终完整性核查 -- 这三份文档是活文档,允许在开发过程中持续修订 - -### 6. 适合真正长期运行 +### 4. 长期运行能力 - 本地优先 - Go 原生 runtime - 多通道接入 - Task Audit / Logs / Memory / Skills / Config / Agents 全链路闭环 -## WebUI 亮点 🖥️ +## WebUI -**Dashboard** +WebUI 当前定位: -![ClawGo Dashboard](docs/assets/readme-dashboard.png) +- Dashboard 与 Agent 拓扑查看 +- 节点、日志、记忆、运行态检查 +- OAuth 账号管理 -**Agents 拓扑** +WebUI 当前不负责: -![ClawGo Agents Topology](docs/assets/readme-agents.png) +- 修改 subagent/runtime 配置 +- 查询或控制公开 task runtime -**Config 工作台** - -![ClawGo Config](docs/assets/readme-config.png) - -## 快速开始 🚀 +## 快速开始 ### 1. 安装 @@ -110,28 +95,18 @@ clawgo provider use openai/gpt-5.4 clawgo provider configure ``` -如果服务商使用 OAuth 登录,例如 `Codex`、`Anthropic`、`Antigravity`、`Gemini CLI`、`Kimi`、`Qwen`: +如果服务商使用 OAuth,例如 `codex`、`anthropic`、`gemini`、`kimi`、`qwen`: ```bash -clawgo provider list clawgo provider login codex clawgo provider login codex --manual ``` -登录完成后会把 OAuth 凭证保存到本地,并自动同步该账号可用模型,后续可直接作为普通 provider 使用。 -回调型 OAuth(如 `codex` / `anthropic` / `antigravity` / `gemini`)在云服务器场景下可使用 `--manual`:服务端打印授权链接,你在桌面浏览器登录后,把最终回调 URL 粘贴回终端即可完成换取 token。 -设备码型 OAuth(如 `kimi` / `qwen`)会直接打印验证链接和用户码,桌面浏览器完成授权后,网关会自动轮询换取 token,无需回填 callback URL。 -对同一个 provider 重复执行 `clawgo provider login codex --manual` 会追加多个 OAuth 账号;当某个账号额度耗尽或触发限流时,会自动切换到下一个已登录账号重试。 -WebUI 也支持发起 OAuth 登录、回填 callback URL、设备码确认、上传 `auth.json`、查看账号列表、手动刷新和删除账号。 - -如果你同时有 `API key` 和 `OAuth` 账号,推荐直接把同一个 provider 配成 `auth: "hybrid"`: +如果同一个 provider 同时有 `API key` 和 `OAuth` 账号,推荐配置 `auth: "hybrid"`: - 优先使用 `api_key` -- 当 `api_key` 触发额度不足、429、限流等错误时,自动切到该 provider 下的 OAuth 账号池 -- OAuth 账号仍然支持多账号轮换、后台预刷新、`auth.json` 导入和 WebUI 管理 -- `oauth.cooldown_sec` 可控制某个 OAuth 账号被限流后暂时熔断多久,默认 `900` -- provider runtime 面板会显示当前候选池排序、最近一次成功命中的凭证,以及最近命中/错误历史 -- 如需在重启后保留 runtime 历史,可给 provider 配置 `runtime_persist`、`runtime_history_file`、`runtime_history_max` +- 额度或限流失败时自动切到 OAuth 账号池 +- 仍保留多账号轮换和后台刷新 ### 4. 启动 @@ -154,7 +129,7 @@ clawgo gateway run make dev ``` -WebUI: +WebUI: ```text http://:/?token= @@ -179,25 +154,22 @@ user -> main -> worker -> main -> user 4. `runtime store` 保存运行态、线程、消息、事件和审计数据 -## 你能用它做什么 +说明: -- 🤖 本地长期运行的个人 Agent -- 🧪 `pm -> coder -> tester` 这种多 Agent 协作链 -- 🌐 本地主控 + 远端 node 分支的分布式执行 -- 🔍 需要强观测、强审计、强恢复的 Agent 系统 -- 🏭 想把 prompt、agent、工具权限、运行策略工程化管理的团队 -- 📝 想把编码过程变成可追踪的 spec-driven delivery 流程 +- `subagent_profile` 保留,用来创建和管理 subagent 定义 +- `spawn` 保留,用来触发 subagent 执行 +- 公开 task runtime、WebUI 配置写入、EKG、watchdog 已移除 ## 配置结构 当前有两层配置视图: -- 落盘文件仍然使用下面的原始结构 -- WebUI 与运行时接口优先使用标准化视图: +- 落盘文件继续使用原始结构 +- 只读接口可暴露标准化视图: - `core` - `runtime` -原始配置的推荐结构: +推荐结构: ```json { @@ -230,87 +202,29 @@ user -> main -> worker -> main -> user 说明: - `runtime_control` 已移除 -- 当前使用: - - `agents.defaults.execution` - - `agents.defaults.summary_policy` - - `agents.router.policy` -- WebUI 配置保存优先走 normalized schema: - - `core.default_provider` - - `core.main_agent_id` - - `core.subagents` - - `runtime.router` - - `runtime.providers` -- 运行态面板优先消费统一 `runtime snapshot / runtime live` +- WebUI 配置编辑已禁用 +- 运行时配置修改请直接改 `config.json` - 启用中的本地 subagent 必须配置 `system_prompt_file` - 远端分支需要: - `transport: "node"` - `node_id` - `parent_agent_id` -完整示例见 [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json)。 - -## Node P2P - -远端 node 的调度数据面现在支持: - -- `websocket_tunnel` -- `webrtc` - -默认仍然关闭,只有显式配置 `gateway.nodes.p2p.enabled=true` 才会启用。建议先用 `websocket_tunnel` 验证链路,再切到 `webrtc`。 - -`webrtc` 建议同时理解这两个字段: - -- `stun_servers` - - 兼容旧式 STUN 列表 -- `ice_servers` - - 推荐的新结构 - - 可以配置 `stun:`、`turn:`、`turns:` URL - - `turn:` / `turns:` 必须同时提供 `username` 和 `credential` - -示例: - -```json -{ - "gateway": { - "nodes": { - "p2p": { - "enabled": true, - "transport": "webrtc", - "stun_servers": ["stun:stun.l.google.com:19302"], - "ice_servers": [ - { - "urls": ["turn:turn.example.com:3478"], - "username": "demo", - "credential": "secret" - } - ] - } - } - } -} -``` - -说明: - -- `webrtc` 建连失败时,调度层仍会回退到现有 relay / tunnel 路径 -- Dashboard、`status`、`/api/nodes` 会显示当前 Node P2P 状态和会话摘要 -- 两台公网机器的实网验证流程见 [docs/node-p2p-e2e.md](/Users/lpf/Desktop/project/clawgo/docs/node-p2p-e2e.md) +完整示例见 [config.example.json](/G:/gopro/clawgo/config.example.json)。 ## MCP 服务支持 -ClawGo 现在支持通过 `tools.mcp` 接入 `stdio`、`http`、`streamable_http`、`sse` 型 MCP server。 +ClawGo 支持通过 `tools.mcp` 接入 `stdio`、`http`、`streamable_http`、`sse` 型 MCP server。 -- 先在 `config.json -> tools.mcp.servers` 里声明 server -- 当前支持 `list_servers`、`list_tools`、`call_tool`、`list_resources`、`read_resource`、`list_prompts`、`get_prompt` -- 启动时会自动发现远端 MCP tools,并注册为本地工具,命名格式为 `mcp____` -- `permission=workspace`(默认)时,`working_dir` 会按 workspace 解析,并且必须留在 workspace 内 -- `permission=full` 时,`working_dir` 可指向 `/` 下任意绝对路径,但实际访问权限仍然继承运行 `clawgo` 的 Linux 用户权限 - -示例配置可直接参考 [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json) 中的 `tools.mcp` 段落。 +- 在 `config.json -> tools.mcp.servers` 中声明 server +- 启动时自动发现远端 MCP tools,并注册为本地工具 +- 工具命名格式为 `mcp____` +- `permission=workspace` 时,`working_dir` 必须留在 workspace 内 +- `permission=full` 时,`working_dir` 可以是任意绝对路径 ## Prompt 文件约定 -推荐把 agent prompt 独立为文件: +推荐把 agent prompt 独立成文件: - `agents/main/AGENT.md` - `agents/coder/AGENT.md` @@ -327,8 +241,8 @@ ClawGo 现在支持通过 `tools.mcp` 接入 `stdio`、`http`、`streamable_http 规则: - 路径必须是 workspace 内相对路径 -- 仓库不会内置这些示例文件 -- 用户或 agent workflow 需要自行创建实际的 `AGENT.md` +- 仓库不内置这些示例 prompt +- 用户或 agent workflow 需要自行创建实际文件 ## 记忆与运行态 @@ -340,7 +254,8 @@ ClawGo 不是所有 agent 共用一份上下文。 - 使用独立 session key - 写入自己的 memory namespace - `runtime store` - - 持久化任务、事件、线程、消息 + - 持久化 runs、events、threads、messages + - 内部仍可使用 task 建模,但不再暴露成公开控制面 这带来三件事: @@ -348,10 +263,11 @@ ClawGo 不是所有 agent 共用一份上下文。 - 更好追踪 - 更清晰的执行边界 -## 当前最适合的人群 +## 适合谁 - 想用 Go 做 Agent Runtime 的开发者 -- 想要可视化多 Agent 拓扑和内部流的团队 -- 不满足于“聊天 + prompt”,而想要真正运行时能力的用户 +- 想要可视化多 Agent 拓扑和内部协作流的团队 +- 需要强观测、强审计、强恢复能力的系统 +- 想把 agent 配置、prompt、工具权限工程化管理的团队 -如果你想快速上手,先看 [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json),再跑一次 `make dev`。 +如果你想快速上手,先看 [config.example.json](/G:/gopro/clawgo/config.example.json),再跑一次 `make dev`。 diff --git a/README_EN.md b/README_EN.md index 186fbb1..ddc57f4 100644 --- a/README_EN.md +++ b/README_EN.md @@ -4,8 +4,8 @@ ClawGo is not just another chat wrapper. It is a long-running, observable, recoverable, orchestrated runtime for real agent systems. -- 👀 **Observable**: agent topology, internal streams, task audit, and EKG visibility -- 🔁 **Recoverable**: persisted runtime state, restart recovery, progress-aware watchdog +- 👀 **Observable**: agent topology, internal streams, task audit, and runtime visibility +- 🔁 **Recoverable**: persisted runtime state and restart recovery - 🧩 **Orchestrated**: `main agent -> subagent -> main`, with local and remote node branches - ⚙️ **Operational**: `config.json`, `AGENT.md`, hot reload, WebUI, declarative registry @@ -46,19 +46,14 @@ In one line: - `agent_messages.jsonl` - running tasks can recover after restart -### 3. Progress-aware watchdog - -- system timeouts go through one global watchdog -- active tasks are extended instead of being killed by a fixed wall-clock timeout -- tasks time out only when they stop making progress - -### 4. Engineering-first agent configuration +### 3. Engineering-first agent configuration - agent registry in `config.json` - `system_prompt_file -> AGENT.md` -- WebUI for editing, hot reload, and runtime inspection +- WebUI for inspection, account management, and runtime status +- runtime config changes are file-driven, not edited from WebUI -### 5. Built for long-running use +### 4. Built for long-running use - local-first - Go-native runtime @@ -183,7 +178,7 @@ ClawGo currently has four layers: There are now two configuration views: - the persisted file still uses the raw structure shown below -- the WebUI and runtime-facing APIs prefer a normalized view: +- read APIs may expose a normalized view: - `core` - `runtime` @@ -224,12 +219,8 @@ Notes: - `agents.defaults.execution` - `agents.defaults.summary_policy` - `agents.router.policy` -- the WebUI now saves through the normalized schema first: - - `core.default_provider` - - `core.main_agent_id` - - `core.subagents` - - `runtime.router` - - `runtime.providers` +- WebUI config editing is disabled +- keep runtime config changes in `config.json` - runtime panels now consume the unified `runtime snapshot / runtime live` - enabled local subagents must define `system_prompt_file` - remote branches require: @@ -331,6 +322,7 @@ ClawGo does not treat all agents as one shared context. - writes to its own memory namespace - `runtime store` - persists runs, events, threads, and messages + - keeps the task model internal to the runtime instead of exposing a public task control surface This gives you: diff --git a/cmd/cmd_gateway.go b/cmd/cmd_gateway.go index 3588681..946ede2 100644 --- a/cmd/cmd_gateway.go +++ b/cmd/cmd_gateway.go @@ -177,12 +177,10 @@ func gatewayCmd() { } configureGatewayNodeP2P(agentLoop, registryServer, cfg) registryServer.SetGatewayVersion(version) - registryServer.SetWebUIVersion(version) registryServer.SetConfigPath(getConfigPath()) registryServer.SetToken(cfg.Gateway.Token) registryServer.SetWorkspacePath(cfg.WorkspacePath()) registryServer.SetLogFilePath(cfg.LogFilePath()) - registryServer.SetWebUIDir(filepath.Join(cfg.WorkspacePath(), "webui")) aistudioRelay := wsrelay.NewManager(wsrelay.Options{ Path: "/v1/ws", ProviderFactory: func(r *http.Request) (string, error) { @@ -223,9 +221,6 @@ func gatewayCmd() { } return out }) - registryServer.SetSubagentHandler(func(cctx context.Context, action string, args map[string]interface{}) (interface{}, error) { - return loop.HandleSubagentRuntime(cctx, action, args) - }) registryServer.SetNodeDispatchHandler(func(cctx context.Context, req nodes.Request, mode string) (nodes.Response, error) { return loop.DispatchNodeRequest(cctx, req, mode) }) @@ -456,7 +451,6 @@ func gatewayCmd() { registryServer.SetToken(cfg.Gateway.Token) registryServer.SetWorkspacePath(cfg.WorkspacePath()) registryServer.SetLogFilePath(cfg.LogFilePath()) - registryServer.SetWebUIDir(filepath.Join(cfg.WorkspacePath(), "webui")) configureGatewayNodeP2P(agentLoop, registryServer, cfg) fmt.Println("Config hot-reload applied (logging/metadata only)") return nil @@ -486,7 +480,6 @@ func gatewayCmd() { registryServer.SetToken(cfg.Gateway.Token) registryServer.SetWorkspacePath(cfg.WorkspacePath()) registryServer.SetLogFilePath(cfg.LogFilePath()) - registryServer.SetWebUIDir(filepath.Join(cfg.WorkspacePath(), "webui")) configureGatewayNodeP2P(agentLoop, registryServer, cfg) registryServer.SetWhatsAppBridge(whatsAppBridge, embeddedWhatsAppBridgeBasePath) sentinelService.Stop() diff --git a/cmd/cmd_node.go b/cmd/cmd_node.go index f4f174a..da51a11 100644 --- a/cmd/cmd_node.go +++ b/cmd/cmd_node.go @@ -833,23 +833,17 @@ func executeNodeAgentTask(ctx context.Context, info nodes.NodeInfo, req nodes.Re }, nil } - out, err := loop.HandleSubagentRuntime(ctx, "dispatch_and_wait", map[string]interface{}{ - "task": strings.TrimSpace(req.Task), - "agent_id": remoteAgentID, - "channel": "node", - "chat_id": info.ID, - "wait_timeout_sec": float64(120), - }) + out, err := loop.DispatchSubagentAndWait(ctx, tools.RouterDispatchRequest{ + Task: strings.TrimSpace(req.Task), + AgentID: remoteAgentID, + NotifyMainPolicy: "internal_only", + OriginChannel: "node", + OriginChatID: info.ID, + }, 120*time.Second) if err != nil { return nodes.Response{}, err } - payload, _ := out.(map[string]interface{}) - result := strings.TrimSpace(fmt.Sprint(payload["merged"])) - if result == "" { - if reply, ok := payload["reply"].(*tools.RouterReply); ok { - result = strings.TrimSpace(reply.Result) - } - } + result := strings.TrimSpace(out) artifacts, err := collectNodeArtifacts(executor.workspace, req.Args) if err != nil { return nodes.Response{}, err diff --git a/cmd/cmd_node_test.go b/cmd/cmd_node_test.go index e1333c4..400643a 100644 --- a/cmd/cmd_node_test.go +++ b/cmd/cmd_node_test.go @@ -236,7 +236,7 @@ func TestExecuteNodeRequestRunsLocalMainAgentTask(t *testing.T) { } } -func TestExecuteNodeRequestRunsLocalSubagentTask(t *testing.T) { +func TestExecuteNodeRequestRunsLocalSubagentRun(t *testing.T) { prevCfg := globalConfigPathOverride prevProviderFactory := nodeProviderFactory prevLoopFactory := nodeAgentLoopFactory diff --git a/cmd/cmd_onboard.go b/cmd/cmd_onboard.go index 9206c05..3b9d679 100644 --- a/cmd/cmd_onboard.go +++ b/cmd/cmd_onboard.go @@ -5,25 +5,11 @@ import ( "io/fs" "os" "path/filepath" - "strings" "github.com/YspCoder/clawgo/pkg/config" ) -type onboardOptions struct { - syncWebUIOnly bool -} - func onboard() { - opts := parseOnboardOptions(os.Args[2:]) - if opts.syncWebUIOnly { - cfg := config.DefaultConfig() - workspace := cfg.WorkspacePath() - createWorkspaceTemplates(workspace, true) - fmt.Printf("%s embedded WebUI refreshed in %s\n", logo, filepath.Join(workspace, "webui")) - return - } - configPath := getConfigPath() if _, err := os.Stat(configPath); err == nil { @@ -44,7 +30,7 @@ func onboard() { } workspace := cfg.WorkspacePath() - createWorkspaceTemplates(workspace, false) + createWorkspaceTemplates(workspace) fmt.Printf("%s clawgo is ready!\n", logo) fmt.Println("\nNext steps:") @@ -75,16 +61,6 @@ func ensureConfigOnboard(configPath string, defaults *config.Config) (string, er return "created", nil } -func parseOnboardOptions(args []string) onboardOptions { - var opts onboardOptions - for _, arg := range args { - if strings.EqualFold(strings.TrimSpace(arg), "--sync-webui") { - opts.syncWebUIOnly = true - } - } - return opts -} - func copyEmbeddedToTarget(targetDir string, overwrite func(relPath string) bool) error { if err := os.MkdirAll(targetDir, 0755); err != nil { return fmt.Errorf("failed to create target directory: %w", err) @@ -127,15 +103,8 @@ func copyEmbeddedToTarget(targetDir string, overwrite func(relPath string) bool) }) } -func createWorkspaceTemplates(workspace string, overwriteWebUI bool) { - var overwrite func(relPath string) bool - if overwriteWebUI { - overwrite = func(relPath string) bool { - relPath = filepath.ToSlash(relPath) - return strings.HasPrefix(relPath, "webui/") - } - } - err := copyEmbeddedToTarget(workspace, overwrite) +func createWorkspaceTemplates(workspace string) { + err := copyEmbeddedToTarget(workspace, nil) if err != nil { fmt.Printf("Error copying workspace templates: %v\n", err) } diff --git a/cmd/cmd_onboard_test.go b/cmd/cmd_onboard_test.go deleted file mode 100644 index a38ad3f..0000000 --- a/cmd/cmd_onboard_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "testing" - - "github.com/YspCoder/clawgo/pkg/config" -) - -func TestParseOnboardOptionsSyncWebUI(t *testing.T) { - t.Parallel() - - opts := parseOnboardOptions([]string{"--sync-webui"}) - if !opts.syncWebUIOnly { - t.Fatalf("expected sync webui option to be enabled") - } -} - -func TestEnsureConfigOnboardGeneratesGatewayToken(t *testing.T) { - t.Parallel() - - configPath := t.TempDir() + "/config.json" - cfg := config.DefaultConfig() - cfg.Gateway.Token = "" - - state, err := ensureConfigOnboard(configPath, cfg) - if err != nil { - t.Fatalf("ensureConfigOnboard failed: %v", err) - } - if state != "created" { - t.Fatalf("unexpected state: %s", state) - } - - loaded, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("load config failed: %v", err) - } - if loaded.Gateway.Token == "" { - t.Fatalf("expected gateway token to be generated") - } -} diff --git a/cmd/cmd_status.go b/cmd/cmd_status.go index 70f90ca..8f6e04b 100644 --- a/cmd/cmd_status.go +++ b/cmd/cmd_status.go @@ -1,11 +1,8 @@ package main import ( - "encoding/json" "fmt" "os" - "path/filepath" - "sort" "strings" "github.com/YspCoder/clawgo/pkg/config" @@ -59,72 +56,6 @@ func statusCmd() { fmt.Printf("Log Max Size: %d MB\n", cfg.Logging.MaxSizeMB) fmt.Printf("Log Retention: %d days\n", cfg.Logging.RetentionDays) } - - fmt.Printf("Heartbeat: enabled=%v interval=%ds ackMaxChars=%d\n", - cfg.Agents.Defaults.Heartbeat.Enabled, - cfg.Agents.Defaults.Heartbeat.EverySec, - cfg.Agents.Defaults.Heartbeat.AckMaxChars, - ) - fmt.Printf("Cron Runtime: workers=%d sleep=%d-%ds\n", - cfg.Cron.MaxWorkers, - cfg.Cron.MinSleepSec, - cfg.Cron.MaxSleepSec, - ) - - heartbeatLog := filepath.Join(workspace, "memory", "heartbeat.log") - if data, err := os.ReadFile(heartbeatLog); err == nil { - trimmed := strings.TrimSpace(string(data)) - if trimmed != "" { - lines := strings.Split(trimmed, "\n") - fmt.Printf("Heartbeat Runs Logged: %d\n", len(lines)) - fmt.Printf("Heartbeat Last Log: %s\n", lines[len(lines)-1]) - } - } - - triggerStats := filepath.Join(workspace, "memory", "trigger-stats.json") - if data, err := os.ReadFile(triggerStats); err == nil { - fmt.Printf("Trigger Stats: %s\n", strings.TrimSpace(string(data))) - } - auditPath := filepath.Join(workspace, "memory", "trigger-audit.jsonl") - if errs, err := collectRecentTriggerErrors(auditPath, 5); err == nil && len(errs) > 0 { - fmt.Println("Recent Trigger Errors:") - for _, e := range errs { - fmt.Printf(" - %s\n", e) - } - } - if agg, err := collectTriggerErrorCounts(auditPath); err == nil && len(agg) > 0 { - fmt.Println("Trigger Error Counts:") - keys := make([]string, 0, len(agg)) - for k := range agg { - keys = append(keys, k) - } - sort.Strings(keys) - for _, trigger := range keys { - fmt.Printf(" %s: %d\n", trigger, agg[trigger]) - } - } - if total, okCnt, failCnt, reasonCov, top, err := collectSkillExecStats(filepath.Join(workspace, "memory", "skill-audit.jsonl")); err == nil && total > 0 { - fmt.Printf("Skill Exec: total=%d ok=%d fail=%d reason_coverage=%.2f\n", total, okCnt, failCnt, reasonCov) - if top != "" { - fmt.Printf("Skill Exec Top: %s\n", top) - } - } - - sessionsDir := filepath.Join(filepath.Dir(configPath), "sessions") - if kinds, err := collectSessionKindCounts(sessionsDir); err == nil && len(kinds) > 0 { - fmt.Println("Session Kinds:") - for _, k := range []string{"main", "cron", "subagent", "hook", "node", "other"} { - if v, ok := kinds[k]; ok { - fmt.Printf(" %s: %d\n", k, v) - } - } - } - if recent, err := collectRecentSubagentSessions(sessionsDir, 5); err == nil && len(recent) > 0 { - fmt.Println("Recent Subagent Sessions:") - for _, key := range recent { - fmt.Printf(" - %s\n", key) - } - } fmt.Printf("Nodes P2P: enabled=%t transport=%s\n", cfg.Gateway.Nodes.P2P.Enabled, strings.TrimSpace(cfg.Gateway.Nodes.P2P.Transport)) fmt.Printf("Nodes P2P ICE: stun=%d ice=%d\n", len(cfg.Gateway.Nodes.P2P.STUNServers), len(cfg.Gateway.Nodes.P2P.ICEServers)) ns := nodes.DefaultManager().List() @@ -156,270 +87,7 @@ func statusCmd() { } fmt.Printf("Nodes: total=%d online=%d\n", len(ns), online) fmt.Printf("Nodes Capabilities: run=%d model=%d camera=%d screen=%d location=%d canvas=%d\n", caps["run"], caps["model"], caps["camera"], caps["screen"], caps["location"], caps["canvas"]) - if total, okCnt, avgMs, actionTop, transportTop, fallbackCnt, err := collectNodeDispatchStats(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl")); err == nil && total > 0 { - fmt.Printf("Nodes Dispatch: total=%d ok=%d fail=%d avg_ms=%d\n", total, okCnt, total-okCnt, avgMs) - if actionTop != "" { - fmt.Printf("Nodes Dispatch Top Action: %s\n", actionTop) - } - if transportTop != "" { - fmt.Printf("Nodes Dispatch Top Transport: %s\n", transportTop) - } - if fallbackCnt > 0 { - fmt.Printf("Nodes Dispatch Fallbacks: %d\n", fallbackCnt) - } - } } } } -func collectSessionKindCounts(sessionsDir string) (map[string]int, error) { - indexPath := filepath.Join(sessionsDir, "sessions.json") - data, err := os.ReadFile(indexPath) - if err != nil { - return nil, err - } - var index map[string]struct { - Kind string `json:"kind"` - } - if err := json.Unmarshal(data, &index); err != nil { - return nil, err - } - counts := map[string]int{} - for _, row := range index { - kind := strings.TrimSpace(strings.ToLower(row.Kind)) - if kind == "" { - kind = "other" - } - counts[kind]++ - } - return counts, nil -} - -func collectRecentTriggerErrors(path string, limit int) ([]string, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - if len(lines) == 1 && strings.TrimSpace(lines[0]) == "" { - return nil, nil - } - out := make([]string, 0, limit) - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if line == "" { - continue - } - var row struct { - Time string `json:"time"` - Trigger string `json:"trigger"` - Error string `json:"error"` - } - if err := json.Unmarshal([]byte(line), &row); err != nil { - continue - } - if strings.TrimSpace(row.Error) == "" { - continue - } - out = append(out, fmt.Sprintf("[%s/%s] %s", row.Time, row.Trigger, row.Error)) - if len(out) >= limit { - break - } - } - return out, nil -} - -func collectTriggerErrorCounts(path string) (map[string]int, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - counts := map[string]int{} - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - var row struct { - Trigger string `json:"trigger"` - Error string `json:"error"` - } - if err := json.Unmarshal([]byte(line), &row); err != nil { - continue - } - if strings.TrimSpace(row.Error) == "" { - continue - } - trigger := strings.ToLower(strings.TrimSpace(row.Trigger)) - if trigger == "" { - trigger = "unknown" - } - counts[trigger]++ - } - return counts, nil -} - -func collectNodeDispatchStats(path string) (int, int, int, string, string, int, error) { - data, err := os.ReadFile(path) - if err != nil { - return 0, 0, 0, "", "", 0, err - } - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - total, okCnt, msSum, fallbackCnt := 0, 0, 0, 0 - actionCnt := map[string]int{} - transportCnt := map[string]int{} - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - var row struct { - Action string `json:"action"` - UsedTransport string `json:"used_transport"` - FallbackFrom string `json:"fallback_from"` - OK bool `json:"ok"` - DurationMS int `json:"duration_ms"` - } - if err := json.Unmarshal([]byte(line), &row); err != nil { - continue - } - total++ - if row.OK { - okCnt++ - } - if row.DurationMS > 0 { - msSum += row.DurationMS - } - a := strings.TrimSpace(strings.ToLower(row.Action)) - if a == "" { - a = "unknown" - } - actionCnt[a]++ - used := strings.TrimSpace(strings.ToLower(row.UsedTransport)) - if used != "" { - transportCnt[used]++ - } - if strings.TrimSpace(row.FallbackFrom) != "" { - fallbackCnt++ - } - } - avg := 0 - if total > 0 { - avg = msSum / total - } - topAction := "" - topN := 0 - for k, v := range actionCnt { - if v > topN { - topN = v - topAction = k - } - } - if topAction != "" { - topAction = fmt.Sprintf("%s(%d)", topAction, topN) - } - topTransport := "" - topTN := 0 - for k, v := range transportCnt { - if v > topTN { - topTN = v - topTransport = k - } - } - if topTransport != "" { - topTransport = fmt.Sprintf("%s(%d)", topTransport, topTN) - } - return total, okCnt, avg, topAction, topTransport, fallbackCnt, nil -} - -func collectSkillExecStats(path string) (int, int, int, float64, string, error) { - data, err := os.ReadFile(path) - if err != nil { - return 0, 0, 0, 0, "", err - } - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - total, okCnt, failCnt := 0, 0, 0 - reasonCnt := 0 - skillCounts := map[string]int{} - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - var row struct { - Skill string `json:"skill"` - Reason string `json:"reason"` - OK bool `json:"ok"` - } - if err := json.Unmarshal([]byte(line), &row); err != nil { - continue - } - total++ - if row.OK { - okCnt++ - } else { - failCnt++ - } - r := strings.TrimSpace(strings.ToLower(row.Reason)) - if r != "" && r != "unspecified" { - reasonCnt++ - } - s := strings.TrimSpace(row.Skill) - if s == "" { - s = "unknown" - } - skillCounts[s]++ - } - topSkill := "" - topN := 0 - for k, v := range skillCounts { - if v > topN { - topN = v - topSkill = k - } - } - if topSkill != "" { - topSkill = fmt.Sprintf("%s(%d)", topSkill, topN) - } - reasonCoverage := 0.0 - if total > 0 { - reasonCoverage = float64(reasonCnt) / float64(total) - } - return total, okCnt, failCnt, reasonCoverage, topSkill, nil -} - -func collectRecentSubagentSessions(sessionsDir string, limit int) ([]string, error) { - indexPath := filepath.Join(sessionsDir, "sessions.json") - data, err := os.ReadFile(indexPath) - if err != nil { - return nil, err - } - var index map[string]struct { - Kind string `json:"kind"` - UpdatedAt int64 `json:"updatedAt"` - } - if err := json.Unmarshal(data, &index); err != nil { - return nil, err - } - type item struct { - key string - updated int64 - } - items := make([]item, 0) - for key, row := range index { - if strings.ToLower(strings.TrimSpace(row.Kind)) != "subagent" { - continue - } - items = append(items, item{key: key, updated: row.UpdatedAt}) - } - sort.Slice(items, func(i, j int) bool { return items[i].updated > items[j].updated }) - if limit > 0 && len(items) > limit { - items = items[:limit] - } - out := make([]string, 0, len(items)) - for _, it := range items { - out = append(out, it.key) - } - return out, nil -} diff --git a/cmd/cmd_tui.go b/cmd/cmd_tui.go index 84ee13b..8d21c31 100644 --- a/cmd/cmd_tui.go +++ b/cmd/cmd_tui.go @@ -1238,7 +1238,7 @@ type tuiClient struct { func (c *tuiClient) ping() error { ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() - req, err := c.newRequest(ctx, http.MethodGet, "/webui/api/version", nil) + req, err := c.newRequest(ctx, http.MethodGet, "/api/version", nil) if err != nil { return err } @@ -1255,7 +1255,7 @@ func (c *tuiClient) ping() error { } func (c *tuiClient) history(ctx context.Context, session string) ([]tuiMessage, error) { - req, err := c.newRequest(ctx, http.MethodGet, "/webui/api/chat/history?session="+url.QueryEscape(session), nil) + req, err := c.newRequest(ctx, http.MethodGet, "/api/chat/history?session="+url.QueryEscape(session), nil) if err != nil { return nil, err } @@ -1278,7 +1278,7 @@ func (c *tuiClient) history(ctx context.Context, session string) ([]tuiMessage, } func (c *tuiClient) sessions(ctx context.Context) ([]tuiSession, error) { - req, err := c.newRequest(ctx, http.MethodGet, "/webui/api/sessions", nil) + req, err := c.newRequest(ctx, http.MethodGet, "/api/sessions", nil) if err != nil { return nil, err } @@ -1331,7 +1331,7 @@ func (c *tuiClient) streamChat(ctx context.Context, session, content string, sen } func (c *tuiClient) openChatSocket(ctx context.Context) (*websocket.Conn, error) { - wsURL, err := c.websocketURL("/webui/api/chat/live") + wsURL, err := c.websocketURL("/api/chat/live") if err != nil { return nil, err } diff --git a/config.example.json b/config.example.json index 09e4e21..0bcc124 100644 --- a/config.example.json +++ b/config.example.json @@ -29,7 +29,7 @@ "execution": { "run_state_ttl_seconds": 1800, "run_state_max": 500, - "tool_parallel_safe_names": ["read_file", "list_files", "find_files", "grep_files", "memory_search", "web_search", "repo_map", "system_info"], + "tool_parallel_safe_names": ["read_file", "list_files", "find_files", "grep_files", "memory_search", "web_search", "system_info"], "tool_max_parallel_calls": 2 }, "summary_policy": { @@ -63,7 +63,6 @@ "allow_direct_agent_chat": false, "max_hops": 6, "default_timeout_sec": 600, - "default_wait_reply": true, "sticky_thread_owner": true }, "communication": { @@ -86,7 +85,7 @@ "accept_from": ["user", "coder", "tester"], "can_talk_to": ["coder", "tester"], "tools": { - "allowlist": ["sessions", "subagents", "memory_search", "repo_map"] + "allowlist": ["sessions", "spawn", "subagent_profile", "memory_search"] }, "runtime": { "provider": "codex", @@ -107,7 +106,7 @@ "accept_from": ["main", "tester"], "can_talk_to": ["main", "tester"], "tools": { - "allowlist": ["filesystem", "shell", "repo_map", "sessions"] + "allowlist": ["filesystem", "shell", "sessions"] }, "runtime": { "provider": "openai", diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 2124a02..6652d93 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -97,10 +97,6 @@ func (cb *ContextBuilder) buildToolsSection() string { return sb.String() } -func (cb *ContextBuilder) BuildSystemPrompt() string { - return cb.BuildSystemPromptWithMemoryNamespace("main") -} - func (cb *ContextBuilder) BuildSystemPromptWithMemoryNamespace(memoryNamespace string) string { parts := []string{} @@ -319,44 +315,6 @@ func (cb *ContextBuilder) BuildMessagesWithMemoryNamespace(history []providers.M return messages } -func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message { - messages = append(messages, providers.Message{ - Role: "tool", - Content: result, - ToolCallID: toolCallID, - }) - return messages -} - -func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, content string, toolCalls []map[string]interface{}) []providers.Message { - msg := providers.Message{ - Role: "assistant", - Content: content, - } - // Always add assistant message, whether or not it has tool calls - messages = append(messages, msg) - return messages -} - -func (cb *ContextBuilder) loadSkills() string { - allSkills := cb.skillsLoader.ListSkills() - if len(allSkills) == 0 { - return "" - } - - var skillNames []string - for _, s := range allSkills { - skillNames = append(skillNames, s.Name) - } - - content := cb.skillsLoader.LoadSkillsForContext(skillNames) - if content == "" { - return "" - } - - return "# Skill Definitions\n\n" + content -} - // GetSkillsInfo returns information about loaded skills. func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} { allSkills := cb.skillsLoader.ListSkills() diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index de67170..cc0a088 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -18,7 +18,6 @@ import ( "regexp" "runtime" "sort" - "strconv" "strings" "sync" "time" @@ -27,7 +26,6 @@ import ( "github.com/YspCoder/clawgo/pkg/bus" "github.com/YspCoder/clawgo/pkg/config" "github.com/YspCoder/clawgo/pkg/cron" - "github.com/YspCoder/clawgo/pkg/ekg" "github.com/YspCoder/clawgo/pkg/logger" "github.com/YspCoder/clawgo/pkg/nodes" "github.com/YspCoder/clawgo/pkg/providers" @@ -60,14 +58,12 @@ type AgentLoop struct { providerPool map[string]providers.LLMProvider providerResponses map[string]config.ProviderResponsesConfig telegramStreaming bool - ekg *ekg.Engine providerMu sync.RWMutex sessionProvider map[string]string streamMu sync.Mutex sessionStreamed map[string]bool subagentManager *tools.SubagentManager subagentRouter *tools.SubagentRouter - subagentConfigTool *tools.SubagentConfigTool nodeRouter *nodes.Router configPath string subagentDigestMu sync.Mutex @@ -101,9 +97,6 @@ func (al *AgentLoop) SetConfigPath(path string) { return } al.configPath = strings.TrimSpace(path) - if al.subagentConfigTool != nil { - al.subagentConfigTool.SetConfigPath(al.configPath) - } } func (al *AgentLoop) SetNodeP2PTransport(t nodes.Transport) { @@ -158,10 +151,6 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers toolsRegistry.Register(readTool) toolsRegistry.Register(writeTool) toolsRegistry.Register(listTool) - // OpenClaw-compatible aliases - toolsRegistry.Register(tools.NewAliasTool("read", "Read file content (OpenClaw-compatible alias of read_file)", readTool, map[string]string{"file_path": "path"})) - toolsRegistry.Register(tools.NewAliasTool("write", "Write file content (OpenClaw-compatible alias of write_file)", writeTool, map[string]string{"file_path": "path"})) - toolsRegistry.Register(tools.NewAliasTool("edit", "Edit file content (OpenClaw-compatible alias of edit_file)", tools.NewEditFileTool(workspace), map[string]string{"file_path": "path", "old_string": "oldText", "new_string": "newText"})) toolsRegistry.Register(tools.NewExecTool(cfg.Tools.Shell, workspace, processManager)) toolsRegistry.Register(tools.NewProcessTool(processManager)) toolsRegistry.Register(tools.NewSkillExecTool(workspace)) @@ -278,11 +267,8 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers // Register spawn tool subagentManager := tools.NewSubagentManager(provider, workspace, msgBus) subagentRouter := tools.NewSubagentRouter(subagentManager) - subagentConfigTool := tools.NewSubagentConfigTool("") spawnTool := tools.NewSpawnTool(subagentManager) toolsRegistry.Register(spawnTool) - toolsRegistry.Register(tools.NewSubagentsTool(subagentManager)) - toolsRegistry.Register(subagentConfigTool) if store := subagentManager.ProfileStore(); store != nil { toolsRegistry.Register(tools.NewSubagentProfileTool(store)) } @@ -339,14 +325,12 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers audit: newTriggerAudit(workspace), running: false, sessionScheduler: NewSessionScheduler(0), - ekg: ekg.New(workspace), sessionProvider: map[string]string{}, sessionStreamed: map[string]bool{}, providerResponses: map[string]config.ProviderResponsesConfig{}, telegramStreaming: cfg.Channels.Telegram.Streaming, subagentManager: subagentManager, subagentRouter: subagentRouter, - subagentConfigTool: subagentConfigTool, nodeRouter: nodesRouter, subagentDigestDelay: 5 * time.Second, subagentDigests: map[string]*subagentDigestState{}, @@ -427,26 +411,26 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers } // Inject recursive run logic so subagents can use full tool-calling flows. - subagentManager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) { - if task == nil { - return "", fmt.Errorf("subagent task is nil") + subagentManager.SetRunFunc(func(ctx context.Context, run *tools.SubagentRun) (string, error) { + if run == nil { + return "", fmt.Errorf("subagent run is nil") } - if strings.EqualFold(strings.TrimSpace(task.Transport), "node") { - return loop.dispatchNodeSubagentTask(ctx, task) + if strings.EqualFold(strings.TrimSpace(run.Transport), "node") { + return loop.dispatchNodeSubagentRun(ctx, run) } - sessionKey := strings.TrimSpace(task.SessionKey) + sessionKey := strings.TrimSpace(run.SessionKey) if sessionKey == "" { - sessionKey = fmt.Sprintf("subagent:%s", strings.TrimSpace(task.ID)) + sessionKey = fmt.Sprintf("subagent:%s", strings.TrimSpace(run.ID)) } - taskInput := loop.buildSubagentTaskInput(task) - ns := normalizeMemoryNamespace(task.MemoryNS) + taskInput := loop.buildSubagentRunInput(run) + ns := normalizeMemoryNamespace(run.MemoryNS) ctx = withMemoryNamespaceContext(ctx, ns) - ctx = withToolAllowlistContext(ctx, task.ToolAllowlist) - channel := strings.TrimSpace(task.OriginChannel) + ctx = withToolAllowlistContext(ctx, run.ToolAllowlist) + channel := strings.TrimSpace(run.OriginChannel) if channel == "" { channel = "cli" } - chatID := strings.TrimSpace(task.OriginChatID) + chatID := strings.TrimSpace(run.OriginChatID) if chatID == "" { chatID = "direct" } @@ -469,20 +453,20 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers return loop } -func (al *AgentLoop) dispatchNodeSubagentTask(ctx context.Context, task *tools.SubagentTask) (string, error) { - if al == nil || task == nil { - return "", fmt.Errorf("node subagent task is nil") +func (al *AgentLoop) dispatchNodeSubagentRun(ctx context.Context, run *tools.SubagentRun) (string, error) { + if al == nil || run == nil { + return "", fmt.Errorf("node subagent run is nil") } if al.nodeRouter == nil { return "", fmt.Errorf("node router is not configured") } - nodeID := strings.TrimSpace(task.NodeID) + nodeID := strings.TrimSpace(run.NodeID) if nodeID == "" { - return "", fmt.Errorf("node-backed subagent %q missing node_id", task.AgentID) + return "", fmt.Errorf("node-backed subagent %q missing node_id", run.AgentID) } - taskInput := loopTaskInputForNode(task) + taskInput := loopRunInputForNode(run) reqArgs := map[string]interface{}{} - if remoteAgentID := remoteAgentIDForNodeBranch(task.AgentID, nodeID); remoteAgentID != "" { + if remoteAgentID := remoteAgentIDForNodeBranch(run.AgentID, nodeID); remoteAgentID != "" { reqArgs["remote_agent_id"] = remoteAgentID } resp, err := al.nodeRouter.Dispatch(ctx, nodes.Request{ @@ -523,14 +507,14 @@ func remoteAgentIDForNodeBranch(agentID, nodeID string) string { return remote } -func loopTaskInputForNode(task *tools.SubagentTask) string { - if task == nil { +func loopRunInputForNode(run *tools.SubagentRun) string { + if run == nil { return "" } - if parent := strings.TrimSpace(task.ParentAgentID); parent != "" { - return fmt.Sprintf("Parent Agent: %s\nSubtree Branch: %s\n\nTask:\n%s", parent, task.AgentID, strings.TrimSpace(task.Task)) + if parent := strings.TrimSpace(run.ParentAgentID); parent != "" { + return fmt.Sprintf("Parent Agent: %s\nSubtree Branch: %s\n\nTask:\n%s", parent, run.AgentID, strings.TrimSpace(run.Task)) } - return strings.TrimSpace(task.Task) + return strings.TrimSpace(run.Task) } func nodeAgentTaskResult(payload map[string]interface{}) string { @@ -546,12 +530,12 @@ func nodeAgentTaskResult(payload map[string]interface{}) string { return "" } -func (al *AgentLoop) buildSubagentTaskInput(task *tools.SubagentTask) string { - if task == nil { +func (al *AgentLoop) buildSubagentRunInput(run *tools.SubagentRun) string { + if run == nil { return "" } - taskText := strings.TrimSpace(task.Task) - if promptFile := strings.TrimSpace(task.SystemPromptFile); promptFile != "" { + taskText := strings.TrimSpace(run.Task) + if promptFile := strings.TrimSpace(run.SystemPromptFile); promptFile != "" { if promptText := al.readSubagentPromptFile(promptFile); promptText != "" { return fmt.Sprintf("Role Profile Policy (%s):\n%s\n\nTask:\n%s", promptFile, promptText, taskText) } @@ -637,13 +621,6 @@ func (al *AgentLoop) tryFallbackProviders(ctx context.Context, msg bus.InboundMe for _, candidate := range al.providerChain[1:] { candidateNames = append(candidateNames, candidate.name) } - if al.ekg != nil { - errSig := "" - if primaryErr != nil { - errSig = primaryErr.Error() - } - candidateNames = al.ekg.RankProvidersForError(candidateNames, errSig) - } ranked := make([]providerCandidate, 0, len(al.providerChain)-1) used := make([]bool, len(al.providerChain)-1) for _, name := range candidateNames { @@ -667,17 +644,6 @@ func (al *AgentLoop) tryFallbackProviders(ctx context.Context, msg bus.InboundMe continue } resp, err := p.Chat(ctx, messages, toolDefs, candidateModel, options) - if al.ekg != nil { - st := "success" - lg := "fallback provider success" - errSig := "" - if err != nil { - st = "error" - lg = err.Error() - errSig = err.Error() - } - al.ekg.Record(ekg.Event{Session: msg.SessionKey, Channel: msg.Channel, Source: "provider_fallback", Status: st, Provider: candidate.name, Model: candidateModel, ErrSig: errSig, Log: lg}) - } if err == nil { logger.WarnCF("agent", logger.C0150, map[string]interface{}{"provider": candidate.name, "model": candidateModel, "ref": candidate.ref}) return resp, candidate.name, nil @@ -821,15 +787,10 @@ func (al *AgentLoop) consumeSessionStreamed(sessionKey string) bool { } func (al *AgentLoop) processInbound(ctx context.Context, msg bus.InboundMessage) { - taskID := buildAuditTaskID(msg) - started := time.Now() - al.appendTaskAuditEvent(taskID, msg, "running", started, 0, "started", false) - response, err := al.processPlannedMessage(ctx, msg) if err != nil { if errors.Is(err, context.Canceled) { al.audit.Record(al.getTrigger(msg), msg.Channel, msg.SessionKey, true, err) - al.appendTaskAudit(taskID, msg, started, err, true) return } response = fmt.Sprintf("Error processing message: %v", err) @@ -855,111 +816,6 @@ func (al *AgentLoop) processInbound(ctx context.Context, msg bus.InboundMessage) al.bus.PublishOutbound(bus.OutboundMessage{Channel: msg.Channel, ChatID: msg.ChatID, Action: "finalize", Content: response, ReplyToID: replyID}) } al.audit.Record(trigger, msg.Channel, msg.SessionKey, suppressed, err) - al.appendTaskAudit(taskID, msg, started, err, suppressed) -} - -func shortSessionKey(s string) string { - if len(s) <= 8 { - return s - } - return s[:8] -} - -func buildAuditTaskID(msg bus.InboundMessage) string { - trigger := "" - if msg.Metadata != nil { - trigger = strings.ToLower(strings.TrimSpace(msg.Metadata["trigger"])) - } - sessionPart := shortSessionKey(msg.SessionKey) - switch trigger { - case "heartbeat": - if sessionPart == "" { - sessionPart = "default" - } - return "heartbeat:" + sessionPart - default: - return fmt.Sprintf("%s-%d", sessionPart, time.Now().Unix()%100000) - } -} - -func (al *AgentLoop) appendTaskAudit(taskID string, msg bus.InboundMessage, started time.Time, runErr error, suppressed bool) { - status := "success" - logText := "completed" - if runErr != nil { - status = "error" - logText = runErr.Error() - } else if suppressed { - status = "suppressed" - logText = "suppressed" - } - al.appendTaskAuditEvent(taskID, msg, status, started, int(time.Since(started).Milliseconds()), logText, suppressed) -} - -func (al *AgentLoop) appendTaskAuditEvent(taskID string, msg bus.InboundMessage, status string, started time.Time, durationMs int, logText string, suppressed bool) { - if al.workspace == "" { - return - } - path := filepath.Join(al.workspace, "memory", "task-audit.jsonl") - _ = os.MkdirAll(filepath.Dir(path), 0755) - source := "direct" - if msg.Metadata != nil && msg.Metadata["trigger"] != "" { - source = msg.Metadata["trigger"] - } - row := map[string]interface{}{ - "task_id": taskID, - "time": time.Now().UTC().Format(time.RFC3339), - "channel": msg.Channel, - "session": msg.SessionKey, - "chat_id": msg.ChatID, - "sender_id": msg.SenderID, - "status": status, - "source": source, - "idle_run": false, - "duration_ms": durationMs, - "suppressed": suppressed, - "retry_count": 0, - "log": logText, - "input_preview": truncate(strings.ReplaceAll(msg.Content, "\n", " "), 180), - "media_count": len(msg.MediaItems), - "media_items": msg.MediaItems, - "provider": al.getSessionProvider(msg.SessionKey), - "model": al.model, - } - if msg.Metadata != nil { - if v := strings.TrimSpace(msg.Metadata["context_extra_chars"]); v != "" { - if n, err := strconv.Atoi(v); err == nil { - row["context_extra_chars"] = n - } - } - if v := strings.TrimSpace(msg.Metadata["context_ekg_chars"]); v != "" { - if n, err := strconv.Atoi(v); err == nil { - row["context_ekg_chars"] = n - } - } - if v := strings.TrimSpace(msg.Metadata["context_memory_chars"]); v != "" { - if n, err := strconv.Atoi(v); err == nil { - row["context_memory_chars"] = n - } - } - } - if al.ekg != nil { - al.ekg.Record(ekg.Event{ - TaskID: taskID, - Session: msg.SessionKey, - Channel: msg.Channel, - Source: source, - Status: status, - Log: logText, - }) - } - - b, _ := json.Marshal(row) - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return - } - defer f.Close() - _, _ = f.Write(append(b, '\n')) } func sessionShardCount() int { @@ -2449,16 +2305,6 @@ func resolveMessageToolTarget(args map[string]interface{}, fallbackChannel, fall return channel, chatID } -func extractFirstSourceLine(text string) string { - for _, line := range strings.Split(text, "\n") { - t := strings.TrimSpace(line) - if strings.HasPrefix(strings.ToLower(t), "source:") { - return t - } - } - return "" -} - func shouldDropNoReply(text string) bool { t := strings.TrimSpace(text) return strings.EqualFold(t, "NO_REPLY") @@ -2615,7 +2461,7 @@ func (al *AgentLoop) runSubagentDigestTicker() { if al == nil { return } - tick := tools.GlobalWatchdogTick + tick := time.Second if tick <= 0 { tick = time.Second } diff --git a/pkg/agent/loop_audit_test.go b/pkg/agent/loop_audit_test.go deleted file mode 100644 index 9c9bb0a..0000000 --- a/pkg/agent/loop_audit_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package agent - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - "time" - - "github.com/YspCoder/clawgo/pkg/bus" -) - -func TestAppendTaskAuditEventPersistsContextCharStats(t *testing.T) { - t.Parallel() - - workspace := t.TempDir() - al := &AgentLoop{workspace: workspace} - msg := bus.InboundMessage{ - Channel: "chat", - SessionKey: "s1", - Content: "Task Context:\nEKG: repeat_errsig=perm\nTask:\ndeploy", - Metadata: map[string]string{ - "context_extra_chars": "42", - "context_ekg_chars": "18", - "context_memory_chars": "0", - }, - } - - al.appendTaskAuditEvent("task-1", msg, "success", time.Now().Add(-time.Second), 1000, "completed", false) - - b, err := os.ReadFile(filepath.Join(workspace, "memory", "task-audit.jsonl")) - if err != nil { - t.Fatalf("read task audit: %v", err) - } - var row map[string]interface{} - if err := json.Unmarshal(b[:len(b)-1], &row); err != nil { - t.Fatalf("decode task audit row: %v", err) - } - if got := int(row["context_extra_chars"].(float64)); got != 42 { - t.Fatalf("expected context_extra_chars=42, got %d", got) - } - if got := int(row["context_ekg_chars"].(float64)); got != 18 { - t.Fatalf("expected context_ekg_chars=18, got %d", got) - } - if got := int(row["context_memory_chars"].(float64)); got != 0 { - t.Fatalf("expected context_memory_chars=0, got %d", got) - } -} diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index c232358..4672d5b 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -15,15 +15,13 @@ import ( ) // MemoryStore manages persistent memory for the agent. -// - Long-term memory: MEMORY.md (workspace root, compatible with OpenClaw) +// - Long-term memory: MEMORY.md // - Daily notes: memory/YYYY-MM-DD.md -// It also supports legacy locations for backward compatibility. type MemoryStore struct { - workspace string - namespace string - memoryDir string - memoryFile string - legacyMemoryFile string + workspace string + namespace string + memoryDir string + memoryFile string } // NewMemoryStore creates a new MemoryStore with the given workspace path. @@ -41,17 +39,15 @@ func NewMemoryStoreWithNamespace(workspace, namespace string) *MemoryStore { memoryDir := filepath.Join(baseDir, "memory") memoryFile := filepath.Join(baseDir, "MEMORY.md") - legacyMemoryFile := filepath.Join(memoryDir, "MEMORY.md") // Ensure memory directory exists os.MkdirAll(memoryDir, 0755) return &MemoryStore{ - workspace: workspace, - namespace: ns, - memoryDir: memoryDir, - memoryFile: memoryFile, - legacyMemoryFile: legacyMemoryFile, + workspace: workspace, + namespace: ns, + memoryDir: memoryDir, + memoryFile: memoryFile, } } @@ -66,24 +62,6 @@ func (ms *MemoryStore) ReadLongTerm() string { if data, err := os.ReadFile(ms.memoryFile); err == nil { return string(data) } - if data, err := os.ReadFile(ms.legacyMemoryFile); err == nil { - return string(data) - } - return "" -} - -// WriteLongTerm writes content to the long-term memory file (MEMORY.md). -func (ms *MemoryStore) WriteLongTerm(content string) error { - return os.WriteFile(ms.memoryFile, []byte(content), 0644) -} - -// ReadToday reads today's daily note. -// Returns empty string if the file doesn't exist. -func (ms *MemoryStore) ReadToday() string { - todayFile := ms.getTodayFile() - if data, err := os.ReadFile(todayFile); err == nil { - return string(data) - } return "" } @@ -121,17 +99,8 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string { for i := 0; i < days; i++ { date := time.Now().AddDate(0, 0, -i) - // Preferred format: memory/YYYY-MM-DD.md - newPath := filepath.Join(ms.memoryDir, date.Format("2006-01-02")+".md") - if data, err := os.ReadFile(newPath); err == nil { - notes = append(notes, string(data)) - continue - } - - // Backward-compatible format: memory/YYYYMM/YYYYMMDD.md - legacyDate := date.Format("20060102") - legacyPath := filepath.Join(ms.memoryDir, legacyDate[:6], legacyDate+".md") - if data, err := os.ReadFile(legacyPath); err == nil { + path := filepath.Join(ms.memoryDir, date.Format("2006-01-02")+".md") + if data, err := os.ReadFile(path); err == nil { notes = append(notes, string(data)) } } diff --git a/pkg/agent/router_dispatch.go b/pkg/agent/router_dispatch.go index c6c8740..75db2b9 100644 --- a/pkg/agent/router_dispatch.go +++ b/pkg/agent/router_dispatch.go @@ -37,7 +37,7 @@ func (al *AgentLoop) maybeAutoRoute(ctx context.Context, msg bus.InboundMessage) } waitCtx, cancel := context.WithTimeout(ctx, time.Duration(waitTimeout)*time.Second) defer cancel() - task, err := al.subagentRouter.DispatchTask(waitCtx, tools.RouterDispatchRequest{ + run, err := al.subagentRouter.DispatchRun(waitCtx, tools.RouterDispatchRequest{ Task: decision.TaskText, AgentID: decision.TargetAgent, Decision: &decision, @@ -48,16 +48,34 @@ func (al *AgentLoop) maybeAutoRoute(ctx context.Context, msg bus.InboundMessage) if err != nil { return "", true, err } - reply, err := al.subagentRouter.WaitReply(waitCtx, task.ID, 100*time.Millisecond) + reply, err := al.subagentRouter.WaitRun(waitCtx, run.ID, 100*time.Millisecond) if err != nil { return "", true, err } return al.subagentRouter.MergeResults([]*tools.RouterReply{reply}), true, nil } -func resolveAutoRouteTarget(cfg *config.Config, raw string) (string, string) { - decision := resolveDispatchDecision(cfg, raw) - return decision.TargetAgent, decision.TaskText +func (al *AgentLoop) DispatchSubagentAndWait(ctx context.Context, req tools.RouterDispatchRequest, waitTimeout time.Duration) (string, error) { + if al == nil || al.subagentRouter == nil { + return "", nil + } + if ctx == nil { + ctx = context.Background() + } + if waitTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, waitTimeout) + defer cancel() + } + run, err := al.subagentRouter.DispatchRun(ctx, req) + if err != nil { + return "", err + } + reply, err := al.subagentRouter.WaitRun(ctx, run.ID, 100*time.Millisecond) + if err != nil { + return "", err + } + return strings.TrimSpace(al.subagentRouter.MergeResults([]*tools.RouterReply{reply})), nil } func resolveDispatchDecision(cfg *config.Config, raw string) tools.DispatchDecision { diff --git a/pkg/agent/router_dispatch_test.go b/pkg/agent/router_dispatch_test.go index 0b3b851..853c6c4 100644 --- a/pkg/agent/router_dispatch_test.go +++ b/pkg/agent/router_dispatch_test.go @@ -10,18 +10,18 @@ import ( "github.com/YspCoder/clawgo/pkg/tools" ) -func TestResolveAutoRouteTarget(t *testing.T) { +func TestResolveDispatchDecisionExplicitAgent(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Router.Enabled = true cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true, SystemPromptFile: "agents/coder/AGENT.md"} - agentID, task := resolveAutoRouteTarget(cfg, "@coder fix login") - if agentID != "coder" || task != "fix login" { - t.Fatalf("unexpected route target: %s / %s", agentID, task) + decision := resolveDispatchDecision(cfg, "@coder fix login") + if decision.TargetAgent != "coder" || decision.TaskText != "fix login" { + t.Fatalf("unexpected route target: %+v", decision) } } -func TestResolveAutoRouteTargetRulesFirst(t *testing.T) { +func TestResolveDispatchDecisionRulesFirst(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Router.Enabled = true cfg.Agents.Router.Strategy = "rules_first" @@ -29,9 +29,9 @@ func TestResolveAutoRouteTargetRulesFirst(t *testing.T) { cfg.Agents.Subagents["tester"] = config.SubagentConfig{Enabled: true, Role: "testing", SystemPromptFile: "agents/tester/AGENT.md"} cfg.Agents.Router.Rules = []config.AgentRouteRule{{AgentID: "coder", Keywords: []string{"鐧诲綍", "bug"}}} - agentID, task := resolveAutoRouteTarget(cfg, "please fix the login bug and update the code") - if agentID != "coder" || task == "" { - t.Fatalf("expected coder route, got %s / %s", agentID, task) + decision := resolveDispatchDecision(cfg, "please fix the login bug and update the code") + if decision.TargetAgent != "coder" || decision.TaskText == "" { + t.Fatalf("expected coder route, got %+v", decision) } } @@ -45,7 +45,7 @@ func TestMaybeAutoRouteDispatchesExplicitAgentMention(t *testing.T) { workspace := t.TempDir() manager := tools.NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) { + manager.SetRunFunc(func(ctx context.Context, run *tools.SubagentRun) (string, error) { return "auto-routed", nil }) loop := &AgentLoop{ @@ -102,7 +102,7 @@ func TestMaybeAutoRouteDispatchesRulesFirstMatch(t *testing.T) { workspace := t.TempDir() manager := tools.NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) { + manager.SetRunFunc(func(ctx context.Context, run *tools.SubagentRun) (string, error) { return "tested", nil }) loop := &AgentLoop{ @@ -141,14 +141,14 @@ func TestResolveDispatchDecisionIncludesReason(t *testing.T) { } } -func TestResolveAutoRouteTargetSkipsOversizedIntent(t *testing.T) { +func TestResolveDispatchDecisionSkipsOversizedIntent(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Router.Enabled = true cfg.Agents.Router.Policy.IntentMaxInputChars = 5 cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true, SystemPromptFile: "agents/coder/AGENT.md"} - agentID, task := resolveAutoRouteTarget(cfg, "@coder implement auth") - if agentID != "" || task != "" { - t.Fatalf("expected oversized intent to skip routing, got %s / %s", agentID, task) + decision := resolveDispatchDecision(cfg, "@coder implement auth") + if decision.TargetAgent != "" || decision.TaskText != "" { + t.Fatalf("expected oversized intent to skip routing, got %+v", decision) } } diff --git a/pkg/agent/runtime_admin.go b/pkg/agent/runtime_admin.go deleted file mode 100644 index 248d85d..0000000 --- a/pkg/agent/runtime_admin.go +++ /dev/null @@ -1,672 +0,0 @@ -package agent - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/runtimecfg" - "github.com/YspCoder/clawgo/pkg/tools" -) - -func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) { - if al == nil || al.subagentManager == nil { - return nil, fmt.Errorf("subagent runtime is not configured") - } - if al.subagentRouter == nil { - return nil, fmt.Errorf("subagent router is not configured") - } - action = strings.ToLower(strings.TrimSpace(action)) - if action == "" { - action = "list" - } - - sm := al.subagentManager - router := al.subagentRouter - switch action { - case "list": - tasks := sm.ListTasks() - items := make([]*tools.SubagentTask, 0, len(tasks)) - for _, task := range tasks { - items = append(items, cloneSubagentTask(task)) - } - sort.Slice(items, func(i, j int) bool { return items[i].Created > items[j].Created }) - return map[string]interface{}{"items": items}, nil - case "snapshot": - limit := runtimeIntArg(args, "limit", 100) - return map[string]interface{}{"snapshot": sm.RuntimeSnapshot(limit)}, nil - case "get", "info": - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) - if err != nil { - return nil, err - } - task, ok := sm.GetTask(taskID) - if !ok { - return map[string]interface{}{"found": false}, nil - } - return map[string]interface{}{"found": true, "task": cloneSubagentTask(task)}, nil - case "spawn", "create": - taskInput := runtimeStringArg(args, "task") - if taskInput == "" { - return nil, fmt.Errorf("task is required") - } - msg, err := sm.Spawn(ctx, tools.SubagentSpawnOptions{ - Task: taskInput, - Label: runtimeStringArg(args, "label"), - Role: runtimeStringArg(args, "role"), - AgentID: runtimeStringArg(args, "agent_id"), - MaxRetries: runtimeIntArg(args, "max_retries", 0), - RetryBackoff: runtimeIntArg(args, "retry_backoff_ms", 0), - TimeoutSec: runtimeIntArg(args, "timeout_sec", 0), - MaxTaskChars: runtimeIntArg(args, "max_task_chars", 0), - MaxResultChars: runtimeIntArg(args, "max_result_chars", 0), - OriginChannel: fallbackString(runtimeStringArg(args, "channel"), "webui"), - OriginChatID: fallbackString(runtimeStringArg(args, "chat_id"), "webui"), - }) - if err != nil { - return nil, err - } - return map[string]interface{}{"message": msg}, nil - case "dispatch_and_wait": - taskInput := runtimeStringArg(args, "task") - if taskInput == "" { - return nil, fmt.Errorf("task is required") - } - task, err := router.DispatchTask(ctx, tools.RouterDispatchRequest{ - Task: taskInput, - Label: runtimeStringArg(args, "label"), - Role: runtimeStringArg(args, "role"), - AgentID: runtimeStringArg(args, "agent_id"), - NotifyMainPolicy: "internal_only", - ThreadID: runtimeStringArg(args, "thread_id"), - CorrelationID: runtimeStringArg(args, "correlation_id"), - ParentRunID: runtimeStringArg(args, "parent_run_id"), - OriginChannel: fallbackString(runtimeStringArg(args, "channel"), "webui"), - OriginChatID: fallbackString(runtimeStringArg(args, "chat_id"), "webui"), - MaxRetries: runtimeIntArg(args, "max_retries", 0), - RetryBackoff: runtimeIntArg(args, "retry_backoff_ms", 0), - TimeoutSec: runtimeIntArg(args, "timeout_sec", 0), - MaxTaskChars: runtimeIntArg(args, "max_task_chars", 0), - MaxResultChars: runtimeIntArg(args, "max_result_chars", 0), - }) - if err != nil { - return nil, err - } - waitTimeoutSec := runtimeIntArg(args, "wait_timeout_sec", 120) - waitCtx := ctx - var cancel context.CancelFunc - if waitTimeoutSec > 0 { - waitCtx, cancel = context.WithTimeout(ctx, time.Duration(waitTimeoutSec)*time.Second) - defer cancel() - } - reply, err := router.WaitReply(waitCtx, task.ID, 100*time.Millisecond) - if err != nil { - return nil, err - } - return map[string]interface{}{ - "task": cloneSubagentTask(task), - "reply": reply, - "merged": router.MergeResults([]*tools.RouterReply{reply}), - }, nil - case "registry": - cfg := runtimecfg.Get() - items := make([]map[string]interface{}, 0) - if cfg != nil { - items = make([]map[string]interface{}, 0, len(cfg.Agents.Subagents)) - for agentID, subcfg := range cfg.Agents.Subagents { - promptFileFound := false - if strings.TrimSpace(subcfg.SystemPromptFile) != "" { - if absPath, err := al.resolvePromptFilePath(subcfg.SystemPromptFile); err == nil { - if info, statErr := os.Stat(absPath); statErr == nil && !info.IsDir() { - promptFileFound = true - } - } - } - toolInfo := al.describeSubagentTools(subcfg.Tools.Allowlist) - items = append(items, map[string]interface{}{ - "agent_id": agentID, - "enabled": subcfg.Enabled, - "type": subcfg.Type, - "transport": fallbackString(strings.TrimSpace(subcfg.Transport), "local"), - "node_id": strings.TrimSpace(subcfg.NodeID), - "parent_agent_id": strings.TrimSpace(subcfg.ParentAgentID), - "notify_main_policy": fallbackString(strings.TrimSpace(subcfg.NotifyMainPolicy), "final_only"), - "display_name": subcfg.DisplayName, - "role": subcfg.Role, - "description": subcfg.Description, - "system_prompt_file": subcfg.SystemPromptFile, - "prompt_file_found": promptFileFound, - "memory_namespace": subcfg.MemoryNamespace, - "tool_allowlist": append([]string(nil), subcfg.Tools.Allowlist...), - "tool_visibility": toolInfo, - "effective_tools": toolInfo["effective_tools"], - "inherited_tools": toolInfo["inherited_tools"], - "routing_keywords": routeKeywordsForRegistry(cfg.Agents.Router.Rules, agentID), - "managed_by": "config.json", - }) - } - } - if store := sm.ProfileStore(); store != nil { - if profiles, err := store.List(); err == nil { - for _, profile := range profiles { - if strings.TrimSpace(profile.ManagedBy) != "node_registry" { - continue - } - toolInfo := al.describeSubagentTools(profile.ToolAllowlist) - items = append(items, map[string]interface{}{ - "agent_id": profile.AgentID, - "enabled": strings.EqualFold(strings.TrimSpace(profile.Status), "active"), - "type": "node_branch", - "transport": profile.Transport, - "node_id": profile.NodeID, - "parent_agent_id": profile.ParentAgentID, - "notify_main_policy": fallbackString(strings.TrimSpace(profile.NotifyMainPolicy), "final_only"), - "display_name": profile.Name, - "role": profile.Role, - "description": "Node-registered remote main agent branch", - "system_prompt_file": profile.SystemPromptFile, - "prompt_file_found": false, - "memory_namespace": profile.MemoryNamespace, - "tool_allowlist": append([]string(nil), profile.ToolAllowlist...), - "tool_visibility": toolInfo, - "effective_tools": toolInfo["effective_tools"], - "inherited_tools": toolInfo["inherited_tools"], - "routing_keywords": []string{}, - "managed_by": profile.ManagedBy, - }) - } - } - } - sort.Slice(items, func(i, j int) bool { - left, _ := items[i]["agent_id"].(string) - right, _ := items[j]["agent_id"].(string) - return left < right - }) - return map[string]interface{}{"items": items}, nil - case "set_config_subagent_enabled": - agentID := runtimeStringArg(args, "agent_id") - if agentID == "" { - return nil, fmt.Errorf("agent_id is required") - } - if al.isProtectedMainAgent(agentID) { - return nil, fmt.Errorf("main agent %q cannot be disabled", agentID) - } - enabled, ok := runtimeBoolArg(args, "enabled") - if !ok { - return nil, fmt.Errorf("enabled is required") - } - return tools.UpsertConfigSubagent(al.configPath, map[string]interface{}{ - "agent_id": agentID, - "enabled": enabled, - }) - case "delete_config_subagent": - agentID := runtimeStringArg(args, "agent_id") - if agentID == "" { - return nil, fmt.Errorf("agent_id is required") - } - if al.isProtectedMainAgent(agentID) { - return nil, fmt.Errorf("main agent %q cannot be deleted", agentID) - } - return tools.DeleteConfigSubagent(al.configPath, agentID) - case "upsert_config_subagent": - return tools.UpsertConfigSubagent(al.configPath, args) - case "prompt_file_get": - relPath := runtimeStringArg(args, "path") - if relPath == "" { - return nil, fmt.Errorf("path is required") - } - absPath, err := al.resolvePromptFilePath(relPath) - if err != nil { - return nil, err - } - data, err := os.ReadFile(absPath) - if err != nil { - if os.IsNotExist(err) { - return map[string]interface{}{"found": false, "path": relPath, "content": ""}, nil - } - return nil, err - } - return map[string]interface{}{"found": true, "path": relPath, "content": string(data)}, nil - case "prompt_file_set": - relPath := runtimeStringArg(args, "path") - if relPath == "" { - return nil, fmt.Errorf("path is required") - } - content := runtimeRawStringArg(args, "content") - absPath, err := al.resolvePromptFilePath(relPath) - if err != nil { - return nil, err - } - if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { - return nil, err - } - if err := os.WriteFile(absPath, []byte(content), 0644); err != nil { - return nil, err - } - return map[string]interface{}{"ok": true, "path": relPath, "bytes": len(content)}, nil - case "prompt_file_bootstrap": - agentID := runtimeStringArg(args, "agent_id") - if agentID == "" { - return nil, fmt.Errorf("agent_id is required") - } - relPath := runtimeStringArg(args, "path") - if relPath == "" { - relPath = filepath.ToSlash(filepath.Join("agents", agentID, "AGENT.md")) - } - absPath, err := al.resolvePromptFilePath(relPath) - if err != nil { - return nil, err - } - overwrite, _ := args["overwrite"].(bool) - if _, err := os.Stat(absPath); err == nil && !overwrite { - data, readErr := os.ReadFile(absPath) - if readErr != nil { - return nil, readErr - } - return map[string]interface{}{ - "ok": true, - "created": false, - "path": relPath, - "content": string(data), - }, nil - } - if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { - return nil, err - } - content := buildPromptTemplate(agentID, runtimeStringArg(args, "role"), runtimeStringArg(args, "display_name")) - if err := os.WriteFile(absPath, []byte(content), 0644); err != nil { - return nil, err - } - return map[string]interface{}{ - "ok": true, - "created": true, - "path": relPath, - "content": content, - }, nil - case "kill": - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) - if err != nil { - return nil, err - } - ok := sm.KillTask(taskID) - return map[string]interface{}{"ok": ok}, nil - case "resume": - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) - if err != nil { - return nil, err - } - label, ok := sm.ResumeTask(ctx, taskID) - return map[string]interface{}{"ok": ok, "label": label}, nil - case "steer": - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) - if err != nil { - return nil, err - } - msg := runtimeStringArg(args, "message") - if msg == "" { - return nil, fmt.Errorf("message is required") - } - ok := sm.SteerTask(taskID, msg) - return map[string]interface{}{"ok": ok}, nil - case "send": - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) - if err != nil { - return nil, err - } - msg := runtimeStringArg(args, "message") - if msg == "" { - return nil, fmt.Errorf("message is required") - } - ok := sm.SendTaskMessage(taskID, msg) - return map[string]interface{}{"ok": ok}, nil - case "reply": - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) - if err != nil { - return nil, err - } - msg := runtimeStringArg(args, "message") - if msg == "" { - return nil, fmt.Errorf("message is required") - } - ok := sm.ReplyToTask(taskID, runtimeStringArg(args, "message_id"), msg) - return map[string]interface{}{"ok": ok}, nil - case "ack": - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) - if err != nil { - return nil, err - } - messageID := runtimeStringArg(args, "message_id") - if messageID == "" { - return nil, fmt.Errorf("message_id is required") - } - ok := sm.AckTaskMessage(taskID, messageID) - return map[string]interface{}{"ok": ok}, nil - case "thread", "trace": - threadID := runtimeStringArg(args, "thread_id") - if threadID == "" { - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) - if err != nil { - return nil, err - } - task, ok := sm.GetTask(taskID) - if !ok { - return map[string]interface{}{"found": false}, nil - } - threadID = strings.TrimSpace(task.ThreadID) - } - if threadID == "" { - return nil, fmt.Errorf("thread_id is required") - } - thread, ok := sm.Thread(threadID) - if !ok { - return map[string]interface{}{"found": false}, nil - } - items, err := sm.ThreadMessages(threadID, runtimeIntArg(args, "limit", 50)) - if err != nil { - return nil, err - } - return map[string]interface{}{"found": true, "thread": thread, "messages": items}, nil - case "stream": - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) - if err != nil { - return nil, err - } - task, ok := sm.GetTask(taskID) - if !ok { - return map[string]interface{}{"found": false}, nil - } - events, err := sm.Events(taskID, runtimeIntArg(args, "limit", 100)) - if err != nil { - return nil, err - } - var thread *tools.AgentThread - var messages []tools.AgentMessage - if strings.TrimSpace(task.ThreadID) != "" { - if th, ok := sm.Thread(task.ThreadID); ok { - thread = th - } - messages, err = sm.ThreadMessages(task.ThreadID, runtimeIntArg(args, "limit", 100)) - if err != nil { - return nil, err - } - } - stream := mergeSubagentStream(events, messages) - return map[string]interface{}{ - "found": true, - "task": cloneSubagentTask(task), - "thread": thread, - "items": stream, - }, nil - case "inbox": - agentID := runtimeStringArg(args, "agent_id") - if agentID == "" { - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) - if err != nil { - return nil, err - } - task, ok := sm.GetTask(taskID) - if !ok { - return map[string]interface{}{"found": false}, nil - } - agentID = strings.TrimSpace(task.AgentID) - } - if agentID == "" { - return nil, fmt.Errorf("agent_id is required") - } - items, err := sm.Inbox(agentID, runtimeIntArg(args, "limit", 50)) - if err != nil { - return nil, err - } - return map[string]interface{}{"found": true, "agent_id": agentID, "messages": items}, nil - default: - return nil, fmt.Errorf("unsupported action: %s", action) - } -} - -func (al *AgentLoop) describeSubagentTools(allowlist []string) map[string]interface{} { - inherited := implicitSubagentTools() - allTools := make([]string, 0) - if al != nil && al.tools != nil { - allTools = al.tools.List() - sort.Strings(allTools) - } - - normalizedAllow := normalizeToolAllowlist(allowlist) - mode := "allowlist" - effective := make([]string, 0) - if len(normalizedAllow) == 0 { - mode = "unrestricted" - effective = append(effective, allTools...) - } else if _, ok := normalizedAllow["*"]; ok { - mode = "all" - effective = append(effective, allTools...) - } else if _, ok := normalizedAllow["all"]; ok { - mode = "all" - effective = append(effective, allTools...) - } else { - for _, name := range allTools { - if isToolNameAllowed(normalizedAllow, name) || isImplicitlyAllowedSubagentTool(name) { - effective = append(effective, name) - } - } - } - return map[string]interface{}{ - "mode": mode, - "raw_allowlist": append([]string(nil), allowlist...), - "inherited_tools": inherited, - "inherited_tool_count": len(inherited), - "effective_tools": effective, - "effective_tool_count": len(effective), - } -} - -func implicitSubagentTools() []string { - out := make([]string, 0, 1) - if isImplicitlyAllowedSubagentTool("skill_exec") { - out = append(out, "skill_exec") - } - return out -} - -func mergeSubagentStream(events []tools.SubagentRunEvent, messages []tools.AgentMessage) []map[string]interface{} { - items := make([]map[string]interface{}, 0, len(events)+len(messages)) - for _, evt := range events { - items = append(items, map[string]interface{}{ - "kind": "event", - "at": evt.At, - "run_id": evt.RunID, - "agent_id": evt.AgentID, - "event_type": evt.Type, - "status": evt.Status, - "message": evt.Message, - "retry_count": evt.RetryCount, - }) - } - for _, msg := range messages { - items = append(items, map[string]interface{}{ - "kind": "message", - "at": msg.CreatedAt, - "message_id": msg.MessageID, - "thread_id": msg.ThreadID, - "from_agent": msg.FromAgent, - "to_agent": msg.ToAgent, - "reply_to": msg.ReplyTo, - "correlation_id": msg.CorrelationID, - "message_type": msg.Type, - "content": msg.Content, - "status": msg.Status, - "requires_reply": msg.RequiresReply, - }) - } - sort.Slice(items, func(i, j int) bool { - left, _ := items[i]["at"].(int64) - right, _ := items[j]["at"].(int64) - if left != right { - return left < right - } - return fmt.Sprintf("%v", items[i]["kind"]) < fmt.Sprintf("%v", items[j]["kind"]) - }) - return items -} - -func firstNonEmptyString(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return strings.TrimSpace(v) - } - } - return "" -} - -func cloneSubagentTask(in *tools.SubagentTask) *tools.SubagentTask { - if in == nil { - return nil - } - out := *in - if len(in.ToolAllowlist) > 0 { - out.ToolAllowlist = append([]string(nil), in.ToolAllowlist...) - } - if len(in.Steering) > 0 { - out.Steering = append([]string(nil), in.Steering...) - } - if in.SharedState != nil { - out.SharedState = make(map[string]interface{}, len(in.SharedState)) - for k, v := range in.SharedState { - out.SharedState[k] = v - } - } - return &out -} - -func resolveSubagentTaskIDForRuntime(sm *tools.SubagentManager, raw string) (string, error) { - id := strings.TrimSpace(raw) - if id == "" { - return "", fmt.Errorf("id is required") - } - if !strings.HasPrefix(id, "#") { - return id, nil - } - idx, err := strconv.Atoi(strings.TrimPrefix(id, "#")) - if err != nil || idx <= 0 { - return "", fmt.Errorf("invalid subagent index") - } - tasks := sm.ListTasks() - if len(tasks) == 0 { - return "", fmt.Errorf("no subagents") - } - sort.Slice(tasks, func(i, j int) bool { return tasks[i].Created > tasks[j].Created }) - if idx > len(tasks) { - return "", fmt.Errorf("subagent index out of range") - } - return tasks[idx-1].ID, nil -} - -func runtimeStringArg(args map[string]interface{}, key string) string { - return tools.MapStringArg(args, key) -} - -func runtimeRawStringArg(args map[string]interface{}, key string) string { - return tools.MapRawStringArg(args, key) -} - -func runtimeIntArg(args map[string]interface{}, key string, fallback int) int { - return tools.MapIntArg(args, key, fallback) -} - -func runtimeBoolArg(args map[string]interface{}, key string) (bool, bool) { - return tools.MapBoolArg(args, key) -} - -func fallbackString(v, fallback string) string { - if strings.TrimSpace(v) == "" { - return fallback - } - return strings.TrimSpace(v) -} - -func routeKeywordsForRegistry(rules []config.AgentRouteRule, agentID string) []string { - agentID = strings.TrimSpace(agentID) - for _, rule := range rules { - if strings.TrimSpace(rule.AgentID) == agentID { - return append([]string(nil), rule.Keywords...) - } - } - return nil -} - -func (al *AgentLoop) isProtectedMainAgent(agentID string) bool { - agentID = strings.TrimSpace(agentID) - if agentID == "" { - return false - } - cfg := runtimecfg.Get() - if cfg == nil { - return agentID == "main" - } - mainID := strings.TrimSpace(cfg.Agents.Router.MainAgentID) - if mainID == "" { - mainID = "main" - } - return agentID == mainID -} - -func (al *AgentLoop) resolvePromptFilePath(relPath string) (string, error) { - relPath = strings.TrimSpace(relPath) - if relPath == "" { - return "", fmt.Errorf("path is required") - } - if filepath.IsAbs(relPath) { - return "", fmt.Errorf("path must be relative") - } - cleaned := filepath.Clean(relPath) - if cleaned == "." || strings.HasPrefix(cleaned, "..") { - return "", fmt.Errorf("path must stay within workspace") - } - workspace := "." - if al != nil && strings.TrimSpace(al.workspace) != "" { - workspace = al.workspace - } - return filepath.Join(workspace, cleaned), nil -} - -func buildPromptTemplate(agentID, role, displayName string) string { - agentID = strings.TrimSpace(agentID) - role = strings.TrimSpace(role) - displayName = strings.TrimSpace(displayName) - title := displayName - if title == "" { - title = agentID - } - if title == "" { - title = "subagent" - } - if role == "" { - role = "worker" - } - return strings.TrimSpace(fmt.Sprintf(`# %s - -## Role -You are the %s subagent. Work within your role boundary and report concrete outcomes. - -## Priorities -- Follow workspace-level policy from workspace/AGENTS.md. -- Complete the assigned task directly. Do not redefine the objective. -- Prefer concrete edits, verification, and concise reporting over long analysis. - -## Collaboration -- Treat the main agent as the coordinator unless the task explicitly says otherwise. -- Surface blockers, assumptions, and verification status in your reply. -- Keep outputs short and execution-focused. - -## Output Format -- Summary: what you changed or checked. -- Risks: anything not verified or still uncertain. -- Next: the most useful immediate follow-up, if any. -`, title, role)) -} diff --git a/pkg/agent/runtime_admin_test.go b/pkg/agent/runtime_admin_test.go deleted file mode 100644 index 47c3299..0000000 --- a/pkg/agent/runtime_admin_test.go +++ /dev/null @@ -1,485 +0,0 @@ -package agent - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/runtimecfg" - "github.com/YspCoder/clawgo/pkg/tools" -) - -func TestHandleSubagentRuntimeDispatchAndWait(t *testing.T) { - workspace := t.TempDir() - manager := tools.NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) { - return "runtime-admin-result", nil - }) - loop := &AgentLoop{ - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - - out, err := loop.HandleSubagentRuntime(context.Background(), "dispatch_and_wait", map[string]interface{}{ - "task": "implement runtime action", - "agent_id": "coder", - "channel": "webui", - "chat_id": "webui", - "wait_timeout_sec": float64(5), - }) - if err != nil { - t.Fatalf("dispatch_and_wait failed: %v", err) - } - payload, ok := out.(map[string]interface{}) - if !ok { - t.Fatalf("unexpected payload type: %T", out) - } - reply, ok := payload["reply"].(*tools.RouterReply) - if !ok { - t.Fatalf("expected router reply, got %T", payload["reply"]) - } - if reply.Status != "completed" || reply.Result != "runtime-admin-result" { - t.Fatalf("unexpected reply: %+v", reply) - } - merged, _ := payload["merged"].(string) - if merged == "" { - t.Fatalf("expected merged output") - } - time.Sleep(20 * time.Millisecond) -} - -func TestHandleSubagentRuntimeUpsertConfigSubagent(t *testing.T) { - workspace := t.TempDir() - configPath := filepath.Join(workspace, "config.json") - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - SystemPromptFile: "agents/main/AGENT.md", - } - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - manager := tools.NewSubagentManager(nil, workspace, nil) - loop := &AgentLoop{ - configPath: configPath, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - out, err := loop.HandleSubagentRuntime(context.Background(), "upsert_config_subagent", map[string]interface{}{ - "agent_id": "reviewer", - "role": "testing", - "notify_main_policy": "internal_only", - "display_name": "Review Agent", - "system_prompt_file": "agents/reviewer/AGENT.md", - "routing_keywords": []interface{}{"review", "regression"}, - "tool_allowlist": []interface{}{"shell", "sessions"}, - }) - if err != nil { - t.Fatalf("upsert config subagent failed: %v", err) - } - payload, ok := out.(map[string]interface{}) - if !ok || payload["ok"] != true { - t.Fatalf("unexpected payload: %#v", out) - } - reloaded, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("reload config failed: %v", err) - } - subcfg, ok := reloaded.Agents.Subagents["reviewer"] - if !ok || subcfg.DisplayName != "Review Agent" { - t.Fatalf("expected reviewer subagent in config, got %+v", reloaded.Agents.Subagents) - } - if subcfg.SystemPromptFile != "agents/reviewer/AGENT.md" { - t.Fatalf("expected system_prompt_file to persist, got %+v", subcfg) - } - if subcfg.NotifyMainPolicy != "internal_only" { - t.Fatalf("expected notify_main_policy to persist, got %+v", subcfg) - } - if len(reloaded.Agents.Router.Rules) == 0 { - t.Fatalf("expected router rules to be persisted") - } - data, err := os.ReadFile(configPath) - if err != nil || len(data) == 0 { - t.Fatalf("expected config file to be written") - } -} - -func TestHandleSubagentRuntimeRegistryAndToggleEnabled(t *testing.T) { - workspace := t.TempDir() - configPath := filepath.Join(workspace, "config.json") - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - SystemPromptFile: "agents/main/AGENT.md", - } - cfg.Agents.Subagents["tester"] = config.SubagentConfig{ - Enabled: true, - Type: "worker", - Role: "testing", - DisplayName: "Test Agent", - SystemPromptFile: "agents/tester/AGENT.md", - MemoryNamespace: "tester", - Tools: config.SubagentToolsConfig{ - Allowlist: []string{"shell", "sessions"}, - }, - } - cfg.Agents.Router.Rules = []config.AgentRouteRule{{AgentID: "tester", Keywords: []string{"test", "regression"}}} - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - manager := tools.NewSubagentManager(nil, workspace, nil) - loop := &AgentLoop{ - configPath: configPath, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - out, err := loop.HandleSubagentRuntime(context.Background(), "registry", nil) - if err != nil { - t.Fatalf("registry failed: %v", err) - } - payload, ok := out.(map[string]interface{}) - if !ok { - t.Fatalf("unexpected registry payload: %T", out) - } - items, ok := payload["items"].([]map[string]interface{}) - if !ok || len(items) < 2 { - t.Fatalf("expected registry items, got %#v", payload["items"]) - } - var tester map[string]interface{} - for _, item := range items { - if item["agent_id"] == "tester" { - tester = item - break - } - } - if tester == nil { - t.Fatalf("expected tester registry item, got %#v", items) - } - toolVisibility, _ := tester["tool_visibility"].(map[string]interface{}) - if toolVisibility == nil { - t.Fatalf("expected tool_visibility in tester registry item, got %#v", tester) - } - if toolVisibility["mode"] != "allowlist" { - t.Fatalf("expected tester tool mode allowlist, got %#v", toolVisibility) - } - inherited, _ := tester["inherited_tools"].([]string) - if len(inherited) != 1 || inherited[0] != "skill_exec" { - t.Fatalf("expected inherited skill_exec, got %#v", tester["inherited_tools"]) - } - - _, err = loop.HandleSubagentRuntime(context.Background(), "set_config_subagent_enabled", map[string]interface{}{ - "agent_id": "tester", - "enabled": false, - }) - if err != nil { - t.Fatalf("toggle enabled failed: %v", err) - } - reloaded, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("reload config failed: %v", err) - } - if reloaded.Agents.Subagents["tester"].Enabled { - t.Fatalf("expected tester to be disabled") - } -} - -func TestHandleSubagentRuntimeDeleteConfigSubagent(t *testing.T) { - workspace := t.TempDir() - configPath := filepath.Join(workspace, "config.json") - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - SystemPromptFile: "agents/main/AGENT.md", - } - cfg.Agents.Subagents["tester"] = config.SubagentConfig{ - Enabled: true, - Type: "worker", - Role: "testing", - SystemPromptFile: "agents/tester/AGENT.md", - } - cfg.Agents.Router.Rules = []config.AgentRouteRule{{AgentID: "tester", Keywords: []string{"test"}}} - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - manager := tools.NewSubagentManager(nil, workspace, nil) - loop := &AgentLoop{ - configPath: configPath, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - out, err := loop.HandleSubagentRuntime(context.Background(), "delete_config_subagent", map[string]interface{}{"agent_id": "tester"}) - if err != nil { - t.Fatalf("delete config subagent failed: %v", err) - } - payload, ok := out.(map[string]interface{}) - if !ok || payload["ok"] != true { - t.Fatalf("unexpected delete payload: %#v", out) - } - reloaded, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("reload config failed: %v", err) - } - if _, ok := reloaded.Agents.Subagents["tester"]; ok { - t.Fatalf("expected tester to be removed") - } - if len(reloaded.Agents.Router.Rules) != 0 { - t.Fatalf("expected tester route rule to be removed") - } -} - -func TestHandleSubagentRuntimeToggleEnabledParsesStringBool(t *testing.T) { - workspace := t.TempDir() - configPath := filepath.Join(workspace, "config.json") - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - SystemPromptFile: "agents/main/AGENT.md", - } - cfg.Agents.Subagents["tester"] = config.SubagentConfig{ - Enabled: true, - Type: "worker", - Role: "testing", - SystemPromptFile: "agents/tester/AGENT.md", - } - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - manager := tools.NewSubagentManager(nil, workspace, nil) - loop := &AgentLoop{ - configPath: configPath, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - if _, err := loop.HandleSubagentRuntime(context.Background(), "set_config_subagent_enabled", map[string]interface{}{ - "agent_id": "tester", - "enabled": "false", - }); err != nil { - t.Fatalf("toggle enabled failed: %v", err) - } - reloaded, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("reload config failed: %v", err) - } - if reloaded.Agents.Subagents["tester"].Enabled { - t.Fatalf("expected tester to be disabled") - } -} - -func TestHandleSubagentRuntimePromptFileGetSetBootstrap(t *testing.T) { - workspace := t.TempDir() - manager := tools.NewSubagentManager(nil, workspace, nil) - loop := &AgentLoop{ - workspace: workspace, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - - out, err := loop.HandleSubagentRuntime(context.Background(), "prompt_file_get", map[string]interface{}{ - "path": "agents/coder/AGENT.md", - }) - if err != nil { - t.Fatalf("prompt_file_get failed: %v", err) - } - payload, ok := out.(map[string]interface{}) - if !ok || payload["found"] != false { - t.Fatalf("expected missing prompt file, got %#v", out) - } - - out, err = loop.HandleSubagentRuntime(context.Background(), "prompt_file_bootstrap", map[string]interface{}{ - "agent_id": "coder", - "role": "coding", - }) - if err != nil { - t.Fatalf("prompt_file_bootstrap failed: %v", err) - } - payload, ok = out.(map[string]interface{}) - if !ok || payload["created"] != true { - t.Fatalf("expected prompt file bootstrap to create file, got %#v", out) - } - - out, err = loop.HandleSubagentRuntime(context.Background(), "prompt_file_set", map[string]interface{}{ - "path": "agents/coder/AGENT.md", - "content": "# coder\nupdated", - }) - if err != nil { - t.Fatalf("prompt_file_set failed: %v", err) - } - payload, ok = out.(map[string]interface{}) - if !ok || payload["ok"] != true { - t.Fatalf("expected prompt_file_set ok, got %#v", out) - } - - out, err = loop.HandleSubagentRuntime(context.Background(), "prompt_file_get", map[string]interface{}{ - "path": "agents/coder/AGENT.md", - }) - if err != nil { - t.Fatalf("prompt_file_get after set failed: %v", err) - } - payload, ok = out.(map[string]interface{}) - if !ok || payload["found"] != true || payload["content"] != "# coder\nupdated" { - t.Fatalf("unexpected prompt file payload: %#v", out) - } -} - -func TestHandleSubagentRuntimeProtectsMainAgent(t *testing.T) { - workspace := t.TempDir() - configPath := filepath.Join(workspace, "config.json") - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Router.MainAgentID = "main" - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - SystemPromptFile: "agents/main/AGENT.md", - } - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - manager := tools.NewSubagentManager(nil, workspace, nil) - loop := &AgentLoop{ - configPath: configPath, - workspace: workspace, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - if _, err := loop.HandleSubagentRuntime(context.Background(), "set_config_subagent_enabled", map[string]interface{}{ - "agent_id": "main", - "enabled": false, - }); err == nil { - t.Fatalf("expected disabling main agent to fail") - } - if _, err := loop.HandleSubagentRuntime(context.Background(), "delete_config_subagent", map[string]interface{}{ - "agent_id": "main", - }); err == nil { - t.Fatalf("expected deleting main agent to fail") - } -} - -func TestHandleSubagentRuntimeStream(t *testing.T) { - workspace := t.TempDir() - manager := tools.NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) { - return "stream-result", nil - }) - loop := &AgentLoop{ - workspace: workspace, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - - out, err := loop.HandleSubagentRuntime(context.Background(), "spawn", map[string]interface{}{ - "task": "prepare streamable task", - "agent_id": "coder", - "channel": "webui", - "chat_id": "webui", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - payload, ok := out.(map[string]interface{}) - if !ok { - t.Fatalf("unexpected spawn payload: %T", out) - } - _ = payload - var task *tools.SubagentTask - for i := 0; i < 50; i++ { - tasks := manager.ListTasks() - if len(tasks) > 0 && tasks[0].Status == "completed" { - task = tasks[0] - break - } - time.Sleep(10 * time.Millisecond) - } - if task == nil { - t.Fatalf("expected completed task") - } - - out, err = loop.HandleSubagentRuntime(context.Background(), "stream", map[string]interface{}{ - "id": task.ID, - }) - if err != nil { - t.Fatalf("stream failed: %v", err) - } - streamPayload, ok := out.(map[string]interface{}) - if !ok || streamPayload["found"] != true { - t.Fatalf("unexpected stream payload: %#v", out) - } - items, ok := streamPayload["items"].([]map[string]interface{}) - if !ok || len(items) == 0 { - t.Fatalf("expected merged stream items, got %#v", streamPayload["items"]) - } - foundEvent := false - foundMessage := false - for _, item := range items { - switch item["kind"] { - case "event": - foundEvent = true - case "message": - foundMessage = true - } - } - if !foundEvent || !foundMessage { - t.Fatalf("expected merged event and message items, got %#v", items) - } -} - -func TestHandleSubagentRuntimeStreamAll(t *testing.T) { - workspace := t.TempDir() - manager := tools.NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) { - return "stream-all-result", nil - }) - loop := &AgentLoop{ - workspace: workspace, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - - if _, err := loop.HandleSubagentRuntime(context.Background(), "spawn", map[string]interface{}{ - "task": "prepare grouped stream task", - "agent_id": "coder", - "channel": "webui", - "chat_id": "webui", - }); err != nil { - t.Fatalf("spawn failed: %v", err) - } - for i := 0; i < 50; i++ { - tasks := manager.ListTasks() - if len(tasks) > 0 && tasks[0].Status == "completed" { - break - } - time.Sleep(10 * time.Millisecond) - } - -} diff --git a/pkg/agent/session_planner.go b/pkg/agent/session_planner.go index 1bf8321..a044ffb 100644 --- a/pkg/agent/session_planner.go +++ b/pkg/agent/session_planner.go @@ -1,21 +1,17 @@ package agent import ( - "bufio" "context" "encoding/json" "errors" "fmt" "math" - "os" - "path/filepath" "regexp" "strings" "sync" "time" "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/ekg" "github.com/YspCoder/clawgo/pkg/providers" "github.com/YspCoder/clawgo/pkg/scheduling" ) @@ -283,9 +279,6 @@ func (al *AgentLoop) runPlannedTasks(ctx context.Context, msg bus.InboundMessage if enriched.extraChars > 0 { subMsg.Metadata["context_extra_chars"] = fmt.Sprintf("%d", enriched.extraChars) } - if enriched.ekgChars > 0 { - subMsg.Metadata["context_ekg_chars"] = fmt.Sprintf("%d", enriched.ekgChars) - } if enriched.memoryChars > 0 { subMsg.Metadata["context_memory_chars"] = fmt.Sprintf("%d", enriched.memoryChars) } @@ -451,14 +444,12 @@ func summarizePlannedTaskProgressBody(body string, maxLines, maxChars int) strin } type taskPromptHints struct { - ekg string memory string } type plannedTaskPrompt struct { content string extraChars int - ekgChars int memoryChars int } @@ -484,22 +475,11 @@ func (al *AgentLoop) enrichPlannedTaskContents(ctx context.Context, tasks []plan return out } -func (al *AgentLoop) enrichTaskContentWithMemoryAndEKG(ctx context.Context, task plannedTask) string { - return buildPlannedTaskPrompt(task.Content, al.collectTaskPromptHints(ctx, task)).content -} - func (al *AgentLoop) collectTaskPromptHints(ctx context.Context, task plannedTask) taskPromptHints { - hints := taskPromptHints{} - if risk := al.ekgHintForTask(task); risk != "" { - hints.ekg = risk - hints.memory = al.memoryHintForTask(ctx, task, true) - return hints - } - hints.memory = al.memoryHintForTask(ctx, task, false) - return hints + return taskPromptHints{memory: al.memoryHintForTask(ctx, task)} } -func (al *AgentLoop) memoryHintForTask(ctx context.Context, task plannedTask, hasEKG bool) string { +func (al *AgentLoop) memoryHintForTask(ctx context.Context, task plannedTask) string { if al == nil || al.tools == nil { return "" } @@ -508,9 +488,6 @@ func (al *AgentLoop) memoryHintForTask(ctx context.Context, task plannedTask, ha if task.Total > 1 { maxChars = 220 } - if hasEKG { - maxChars = 160 - } args := map[string]interface{}{ "query": task.Content, "maxResults": maxResults, @@ -529,165 +506,6 @@ func (al *AgentLoop) memoryHintForTask(ctx context.Context, task plannedTask, ha return compactMemoryHint(txt, maxChars) } -func (al *AgentLoop) ekgHintForTask(task plannedTask) string { - if al == nil || al.ekg == nil || strings.TrimSpace(al.workspace) == "" { - return "" - } - evt, ok := al.findRecentRelatedErrorEvent(task.Content) - if !ok { - return "" - } - errSig := ekg.NormalizeErrorSignature(evt.Log) - if errSig == "" { - return "" - } - advice := al.ekg.GetAdvice(ekg.SignalContext{ - TaskID: evt.TaskID, - ErrSig: errSig, - Source: evt.Source, - Channel: evt.Channel, - }) - if !advice.ShouldEscalate { - return "" - } - parts := []string{ - fmt.Sprintf("repeat_errsig=%s", truncate(errSig, 72)), - fmt.Sprintf("backoff=%ds", advice.RetryBackoffSec), - } - if evt.Preview != "" { - parts = append(parts, "related_task="+truncate(strings.TrimSpace(evt.Preview), 96)) - } - if len(advice.Reason) > 0 { - parts = append(parts, "reason="+truncate(strings.Join(advice.Reason, "+"), 64)) - } - return strings.Join(parts, "; ") -} - -type taskAuditErrorEvent struct { - TaskID string - Source string - Channel string - Log string - Preview string - MatchScore int - MatchRatio float64 -} - -func (al *AgentLoop) findRecentRelatedErrorEvent(taskContent string) (taskAuditErrorEvent, bool) { - path := filepath.Join(strings.TrimSpace(al.workspace), "memory", "task-audit.jsonl") - f, err := os.Open(path) - if err != nil { - return taskAuditErrorEvent{}, false - } - defer f.Close() - - kw := tokenizeTaskText(taskContent) - if len(kw) == 0 { - return taskAuditErrorEvent{}, false - } - var best taskAuditErrorEvent - bestScore := 0 - bestRatio := 0.0 - - s := bufio.NewScanner(f) - for s.Scan() { - line := strings.TrimSpace(s.Text()) - if line == "" { - continue - } - var row map[string]interface{} - if json.Unmarshal([]byte(line), &row) != nil { - continue - } - if strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["status"]))) != "error" { - continue - } - logText := strings.TrimSpace(fmt.Sprintf("%v", row["log"])) - if logText == "" { - continue - } - preview := strings.TrimSpace(fmt.Sprintf("%v", row["input_preview"])) - previewKW := tokenizeTaskText(preview) - score := overlapScore(kw, previewKW) - ratio := overlapRatio(kw, previewKW, score) - if !isStrongTaskMatch(score, ratio) { - continue - } - if score < bestScore || (score == bestScore && ratio < bestRatio) { - continue - } - bestScore = score - bestRatio = ratio - best = taskAuditErrorEvent{ - TaskID: strings.TrimSpace(fmt.Sprintf("%v", row["task_id"])), - Source: strings.TrimSpace(fmt.Sprintf("%v", row["source"])), - Channel: strings.TrimSpace(fmt.Sprintf("%v", row["channel"])), - Log: logText, - Preview: preview, - MatchScore: score, - MatchRatio: ratio, - } - } - if bestScore == 0 || strings.TrimSpace(best.TaskID) == "" { - return taskAuditErrorEvent{}, false - } - return best, true -} - -func tokenizeTaskText(s string) []string { - normalized := strings.NewReplacer("\n", " ", "\t", " ", ",", " ", ",", " ", ".", " ", "。", " ", ":", " ", ":", " ", ";", " ", ";", " ").Replace(strings.ToLower(strings.TrimSpace(s))) - parts := strings.Fields(normalized) - out := make([]string, 0, len(parts)) - for _, p := range parts { - if len(p) < 3 { - continue - } - out = append(out, p) - } - return out -} - -func overlapScore(a, b []string) int { - if len(a) == 0 || len(b) == 0 { - return 0 - } - set := make(map[string]struct{}, len(a)) - for _, k := range a { - set[k] = struct{}{} - } - score := 0 - for _, k := range b { - if _, ok := set[k]; ok { - score++ - } - } - return score -} - -func overlapRatio(a, b []string, score int) float64 { - if score <= 0 || len(a) == 0 || len(b) == 0 { - return 0 - } - shorter := len(a) - if len(b) < shorter { - shorter = len(b) - } - if shorter <= 0 { - return 0 - } - return float64(score) / float64(shorter) -} - -func isStrongTaskMatch(score int, ratio float64) bool { - if score >= 4 { - return true - } - if score < 2 { - return false - } - return ratio >= 0.35 -} - func compactMemoryHint(raw string, maxChars int) string { raw = strings.ReplaceAll(raw, "\r\n", "\n") lines := strings.Split(raw, "\n") @@ -719,23 +537,16 @@ func compactMemoryHint(raw string, maxChars int) string { return truncate(strings.Join(parts, " | "), maxChars) } -func renderTaskPromptWithHints(taskContent string, hints taskPromptHints) string { - return buildPlannedTaskPrompt(taskContent, hints).content -} - func buildPlannedTaskPrompt(taskContent string, hints taskPromptHints) plannedTaskPrompt { base := strings.TrimSpace(taskContent) if base == "" { return plannedTaskPrompt{} } - if hints.ekg == "" && hints.memory == "" { + if hints.memory == "" { return plannedTaskPrompt{content: base} } lines := make([]string, 0, 4) lines = append(lines, "Task Context:") - if hints.ekg != "" { - lines = append(lines, "EKG: "+hints.ekg) - } if hints.memory != "" { lines = append(lines, "Memory: "+hints.memory) } @@ -744,7 +555,6 @@ func buildPlannedTaskPrompt(taskContent string, hints taskPromptHints) plannedTa return plannedTaskPrompt{ content: content, extraChars: maxInt(len(content)-len(base), 0), - ekgChars: len(hints.ekg), memoryChars: len(hints.memory), } } @@ -761,7 +571,6 @@ func applyPromptBudget(hints *taskPromptHints, remaining *int) { return } if *remaining <= 0 { - hints.ekg = "" hints.memory = "" return } @@ -770,23 +579,13 @@ func applyPromptBudget(hints *taskPromptHints, remaining *int) { return } hints.memory = "" - needed = estimateHintChars(*hints) - if needed <= *remaining { - return - } - hints.ekg = "" } func estimateHintChars(hints taskPromptHints) int { total := 0 - if hints.ekg != "" { - total += len("Task Context:\nEKG: \nTask:\n") + len(hints.ekg) - } if hints.memory != "" { total += len("Memory: \n") + len(hints.memory) - if hints.ekg == "" { - total += len("Task Context:\nTask:\n") - } + total += len("Task Context:\nTask:\n") } return total } @@ -795,14 +594,6 @@ func dedupeTaskPromptHints(hints *taskPromptHints, seen map[string]struct{}) { if hints == nil || seen == nil { return } - if hints.ekg != "" { - key := "ekg:" + hints.ekg - if _, ok := seen[key]; ok { - hints.ekg = "" - } else { - seen[key] = struct{}{} - } - } if hints.memory != "" { key := "memory:" + hints.memory if _, ok := seen[key]; ok { diff --git a/pkg/agent/session_planner_test.go b/pkg/agent/session_planner_test.go index d675513..9fade55 100644 --- a/pkg/agent/session_planner_test.go +++ b/pkg/agent/session_planner_test.go @@ -1,13 +1,8 @@ package agent import ( - "fmt" - "os" - "path/filepath" "strings" "testing" - - "github.com/YspCoder/clawgo/pkg/ekg" ) func TestSummarizePlannedTaskProgressBodyPreservesUsefulLines(t *testing.T) { @@ -27,75 +22,6 @@ func TestSummarizePlannedTaskProgressBodyPreservesUsefulLines(t *testing.T) { } } -func TestEKGHintForTaskRequiresStrongMatchAndStaysCompact(t *testing.T) { - t.Parallel() - - workspace := t.TempDir() - memoryDir := filepath.Join(workspace, "memory") - if err := os.MkdirAll(memoryDir, 0o755); err != nil { - t.Fatalf("mkdir memory: %v", err) - } - logText := "open /srv/app/config.yaml: permission denied after deploy 42" - taskAudit := []string{ - fmt.Sprintf(`{"task_id":"task-1","status":"error","source":"planner","channel":"chat","input_preview":"check nginx logs quickly","log":"%s"}`, logText), - fmt.Sprintf(`{"task_id":"task-2","status":"error","source":"planner","channel":"chat","input_preview":"deploy config service restart on cluster","log":"%s"}`, logText), - } - if err := os.WriteFile(filepath.Join(memoryDir, "task-audit.jsonl"), []byte(strings.Join(taskAudit, "\n")+"\n"), 0o644); err != nil { - t.Fatalf("write task audit: %v", err) - } - errSig := ekg.NormalizeErrorSignature(logText) - ekgEvents := []string{ - fmt.Sprintf(`{"task_id":"task-2","status":"error","errsig":"%s","log":"%s"}`, errSig, logText), - fmt.Sprintf(`{"task_id":"task-2","status":"error","errsig":"%s","log":"%s"}`, errSig, logText), - fmt.Sprintf(`{"task_id":"task-2","status":"error","errsig":"%s","log":"%s"}`, errSig, logText), - } - if err := os.WriteFile(filepath.Join(memoryDir, "ekg-events.jsonl"), []byte(strings.Join(ekgEvents, "\n")+"\n"), 0o644); err != nil { - t.Fatalf("write ekg events: %v", err) - } - - al := &AgentLoop{workspace: workspace, ekg: ekg.New(workspace)} - hint := al.ekgHintForTask(plannedTask{Content: "deploy config service restart after rollout"}) - if hint == "" { - t.Fatalf("expected compact ekg hint") - } - if !strings.Contains(hint, "repeat_errsig=") || !strings.Contains(hint, "backoff=300s") { - t.Fatalf("expected compact fields, got: %s", hint) - } - if strings.Contains(strings.ToLower(hint), "last error") || strings.Contains(hint, logText) { - t.Fatalf("expected raw error log to be omitted, got: %s", hint) - } -} - -func TestEKGHintForTaskSkipsWeakTaskMatch(t *testing.T) { - t.Parallel() - - workspace := t.TempDir() - memoryDir := filepath.Join(workspace, "memory") - if err := os.MkdirAll(memoryDir, 0o755); err != nil { - t.Fatalf("mkdir memory: %v", err) - } - logText := "dial tcp 10.0.0.8:443: i/o timeout" - taskAudit := `{"task_id":"task-3","status":"error","source":"planner","channel":"chat","input_preview":"investigate cache timeout","log":"dial tcp 10.0.0.8:443: i/o timeout"}` - if err := os.WriteFile(filepath.Join(memoryDir, "task-audit.jsonl"), []byte(taskAudit+"\n"), 0o644); err != nil { - t.Fatalf("write task audit: %v", err) - } - errSig := ekg.NormalizeErrorSignature(logText) - ekgEvents := []string{ - fmt.Sprintf(`{"task_id":"task-3","status":"error","errsig":"%s","log":"%s"}`, errSig, logText), - fmt.Sprintf(`{"task_id":"task-3","status":"error","errsig":"%s","log":"%s"}`, errSig, logText), - fmt.Sprintf(`{"task_id":"task-3","status":"error","errsig":"%s","log":"%s"}`, errSig, logText), - } - if err := os.WriteFile(filepath.Join(memoryDir, "ekg-events.jsonl"), []byte(strings.Join(ekgEvents, "\n")+"\n"), 0o644); err != nil { - t.Fatalf("write ekg events: %v", err) - } - - al := &AgentLoop{workspace: workspace, ekg: ekg.New(workspace)} - hint := al.ekgHintForTask(plannedTask{Content: "cache rebuild"}) - if hint != "" { - t.Fatalf("expected weak match to skip ekg hint, got: %s", hint) - } -} - func TestCompactMemoryHintDropsVerboseScaffolding(t *testing.T) { t.Parallel() @@ -116,29 +42,28 @@ func TestDedupeTaskPromptHintsDropsRepeatedContext(t *testing.T) { t.Parallel() seen := map[string]struct{}{} - first := taskPromptHints{ekg: "repeat_errsig=x; backoff=300s", memory: "src=memory/a.md#L1-L2 | restart service"} - second := taskPromptHints{ekg: "repeat_errsig=x; backoff=300s", memory: "src=memory/a.md#L1-L2 | restart service"} + first := taskPromptHints{memory: "src=memory/a.md#L1-L2 | restart service"} + second := taskPromptHints{memory: "src=memory/a.md#L1-L2 | restart service"} dedupeTaskPromptHints(&first, seen) dedupeTaskPromptHints(&second, seen) - if first.ekg == "" || first.memory == "" { + if first.memory == "" { t.Fatalf("expected first hint set to remain: %+v", first) } - if second.ekg != "" || second.memory != "" { + if second.memory != "" { t.Fatalf("expected repeated hints removed: %+v", second) } } -func TestRenderTaskPromptWithHintsKeepsCompactShape(t *testing.T) { +func TestBuildPlannedTaskPromptKeepsCompactShape(t *testing.T) { t.Parallel() - got := renderTaskPromptWithHints("deploy config service", taskPromptHints{ - ekg: "repeat_errsig=perm; backoff=300s", + got := buildPlannedTaskPrompt("deploy config service", taskPromptHints{ memory: "src=memory/x.md#L1-L2 | restart after change", }) - if !strings.Contains(got, "Task Context:\nEKG: repeat_errsig=perm; backoff=300s\nMemory: src=memory/x.md#L1-L2 | restart after change\nTask:\ndeploy config service") { - t.Fatalf("unexpected prompt shape: %s", got) + if !strings.Contains(got.content, "Task Context:\nMemory: src=memory/x.md#L1-L2 | restart after change\nTask:\ndeploy config service") { + t.Fatalf("unexpected prompt shape: %+v", got) } } @@ -146,29 +71,24 @@ func TestBuildPlannedTaskPromptTracksExtraChars(t *testing.T) { t.Parallel() prompt := buildPlannedTaskPrompt("deploy config service", taskPromptHints{ - ekg: "repeat_errsig=perm; backoff=300s", memory: "src=memory/x.md#L1-L2 | restart after change", }) if prompt.content == "" { t.Fatalf("expected prompt content") } - if prompt.extraChars <= 0 || prompt.ekgChars <= 0 || prompt.memoryChars <= 0 { + if prompt.extraChars <= 0 || prompt.memoryChars <= 0 { t.Fatalf("expected prompt stats populated: %+v", prompt) } } -func TestApplyPromptBudgetPrefersEKGOverMemory(t *testing.T) { +func TestApplyPromptBudgetDropsMemoryWhenOverBudget(t *testing.T) { t.Parallel() hints := taskPromptHints{ - ekg: "repeat_errsig=perm; backoff=300s", memory: "src=memory/x.md#L1-L2 | restart after change", } - remaining := estimateHintChars(hints) - len(hints.memory) + remaining := estimateHintChars(hints) - 1 applyPromptBudget(&hints, &remaining) - if hints.ekg == "" { - t.Fatalf("expected ekg retained") - } if hints.memory != "" { t.Fatalf("expected memory dropped under budget pressure: %+v", hints) } @@ -178,12 +98,11 @@ func TestApplyPromptBudgetDropsAllWhenBudgetExhausted(t *testing.T) { t.Parallel() hints := taskPromptHints{ - ekg: "repeat_errsig=perm; backoff=300s", memory: "src=memory/x.md#L1-L2 | restart after change", } remaining := 8 applyPromptBudget(&hints, &remaining) - if hints.ekg != "" || hints.memory != "" { + if hints.memory != "" { t.Fatalf("expected all hints removed under tiny budget: %+v", hints) } } diff --git a/pkg/agent/session_scheduler.go b/pkg/agent/session_scheduler.go index 7a28467..bd4fb57 100644 --- a/pkg/agent/session_scheduler.go +++ b/pkg/agent/session_scheduler.go @@ -113,26 +113,6 @@ func (s *SessionScheduler) Acquire(ctx context.Context, sessionKey string, keys } } -func (s *SessionScheduler) Close() { - if s == nil { - return - } - s.mu.Lock() - defer s.mu.Unlock() - if s.closed { - return - } - s.closed = true - for _, st := range s.sessions { - for _, w := range st.waiters { - select { - case w.ch <- struct{}{}: - default: - } - } - } -} - func (s *SessionScheduler) releaseFunc(sessionKey string, runID uint64) func() { var once sync.Once return func() { diff --git a/pkg/agent/subagent_node_test.go b/pkg/agent/subagent_node_test.go index 9c5106d..845a65c 100644 --- a/pkg/agent/subagent_node_test.go +++ b/pkg/agent/subagent_node_test.go @@ -9,7 +9,7 @@ import ( "github.com/YspCoder/clawgo/pkg/tools" ) -func TestDispatchNodeSubagentTaskUsesNodeAgentTask(t *testing.T) { +func TestDispatchNodeSubagentRunUsesNodeAgentTask(t *testing.T) { manager := nodes.NewManager() manager.Upsert(nodes.NodeInfo{ ID: "edge-dev", @@ -44,7 +44,7 @@ func TestDispatchNodeSubagentTaskUsesNodeAgentTask(t *testing.T) { Relay: &nodes.HTTPRelayTransport{Manager: manager}, }, } - out, err := loop.dispatchNodeSubagentTask(context.Background(), &tools.SubagentTask{ + out, err := loop.dispatchNodeSubagentRun(context.Background(), &tools.SubagentRun{ ID: "subagent-1", AgentID: "node.edge-dev.coder", Transport: "node", @@ -53,7 +53,7 @@ func TestDispatchNodeSubagentTaskUsesNodeAgentTask(t *testing.T) { Task: "Implement fix on remote node", }) if err != nil { - t.Fatalf("dispatchNodeSubagentTask failed: %v", err) + t.Fatalf("dispatchNodeSubagentRun failed: %v", err) } if out != "remote-main-done" { t.Fatalf("unexpected node result: %q", out) diff --git a/pkg/agent/subagent_prompt_test.go b/pkg/agent/subagent_prompt_test.go index 23f8c39..eb2dad3 100644 --- a/pkg/agent/subagent_prompt_test.go +++ b/pkg/agent/subagent_prompt_test.go @@ -9,7 +9,7 @@ import ( "github.com/YspCoder/clawgo/pkg/tools" ) -func TestBuildSubagentTaskInputPrefersPromptFile(t *testing.T) { +func TestBuildSubagentRunInputPrefersPromptFile(t *testing.T) { workspace := t.TempDir() if err := os.MkdirAll(filepath.Join(workspace, "agents", "coder"), 0755); err != nil { t.Fatalf("mkdir failed: %v", err) @@ -18,7 +18,7 @@ func TestBuildSubagentTaskInputPrefersPromptFile(t *testing.T) { t.Fatalf("write AGENT failed: %v", err) } loop := &AgentLoop{workspace: workspace} - input := loop.buildSubagentTaskInput(&tools.SubagentTask{ + input := loop.buildSubagentRunInput(&tools.SubagentRun{ Task: "implement login flow", SystemPromptFile: "agents/coder/AGENT.md", }) @@ -30,9 +30,9 @@ func TestBuildSubagentTaskInputPrefersPromptFile(t *testing.T) { } } -func TestBuildSubagentTaskInputWithoutPromptFileUsesTaskOnly(t *testing.T) { +func TestBuildSubagentRunInputWithoutPromptFileUsesTaskOnly(t *testing.T) { loop := &AgentLoop{workspace: t.TempDir()} - input := loop.buildSubagentTaskInput(&tools.SubagentTask{ + input := loop.buildSubagentRunInput(&tools.SubagentRun{ Task: "run regression", }) if strings.Contains(input, "test inline prompt") { diff --git a/pkg/api/server.go b/pkg/api/server.go index 151a427..63399a6 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -50,7 +50,6 @@ type Server struct { artifactStatsMu sync.Mutex artifactStats map[string]interface{} gatewayVersion string - webuiVersion string configPath string workspacePath string logFilePath string @@ -58,18 +57,8 @@ type Server struct { onChatHistory func(sessionKey string) []map[string]interface{} onConfigAfter func() error onCron func(action string, args map[string]interface{}) (interface{}, error) - onSubagents func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) onNodeDispatch func(ctx context.Context, req nodes.Request, mode string) (nodes.Response, error) onToolsCatalog func() interface{} - webUIDir string - ekgCacheMu sync.Mutex - ekgCachePath string - ekgCacheStamp time.Time - ekgCacheSize int64 - ekgCacheRows []map[string]interface{} - liveRuntimeMu sync.Mutex - liveRuntimeSubs map[chan []byte]struct{} - liveRuntimeOn bool whatsAppBridge *channels.WhatsAppBridgeService whatsAppBase string oauthFlowMu sync.Mutex @@ -97,7 +86,6 @@ func NewServer(host string, port int, token string, mgr *nodes.Manager) *Server nodeConnIDs: map[string]string{}, nodeSockets: map[string]*nodeSocketConn{}, artifactStats: map[string]interface{}{}, - liveRuntimeSubs: map[chan []byte]struct{}{}, oauthFlows: map[string]*providers.OAuthPendingFlow{}, extraRoutes: map[string]http.Handler{}, } @@ -119,87 +107,6 @@ func (c *nodeSocketConn) Send(msg nodes.WireMessage) error { return c.conn.WriteJSON(msg) } -func publishLiveSnapshot(subs map[chan []byte]struct{}, payload []byte) { - for ch := range subs { - select { - case ch <- payload: - default: - select { - case <-ch: - default: - } - select { - case ch <- payload: - default: - } - } - } -} - -func (s *Server) subscribeRuntimeLive(ctx context.Context) chan []byte { - ch := make(chan []byte, 1) - s.liveRuntimeMu.Lock() - s.liveRuntimeSubs[ch] = struct{}{} - start := !s.liveRuntimeOn - if start { - s.liveRuntimeOn = true - } - s.liveRuntimeMu.Unlock() - if start { - go s.runtimeLiveLoop() - } - go func() { - <-ctx.Done() - s.unsubscribeRuntimeLive(ch) - }() - return ch -} - -func (s *Server) unsubscribeRuntimeLive(ch chan []byte) { - s.liveRuntimeMu.Lock() - delete(s.liveRuntimeSubs, ch) - s.liveRuntimeMu.Unlock() -} - -func (s *Server) runtimeLiveLoop() { - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - for { - if !s.publishRuntimeSnapshot(context.Background()) { - s.liveRuntimeMu.Lock() - if len(s.liveRuntimeSubs) == 0 { - s.liveRuntimeOn = false - s.liveRuntimeMu.Unlock() - return - } - s.liveRuntimeMu.Unlock() - } - <-ticker.C - } -} - -func (s *Server) publishRuntimeSnapshot(ctx context.Context) bool { - if s == nil { - return false - } - payload := map[string]interface{}{ - "ok": true, - "type": "runtime_snapshot", - "snapshot": s.buildWebUIRuntimeSnapshot(ctx), - } - data, err := json.Marshal(payload) - if err != nil { - return false - } - s.liveRuntimeMu.Lock() - defer s.liveRuntimeMu.Unlock() - if len(s.liveRuntimeSubs) == 0 { - return false - } - publishLiveSnapshot(s.liveRuntimeSubs, data) - return true -} - func (s *Server) SetConfigPath(path string) { s.configPath = strings.TrimSpace(path) } func (s *Server) SetWorkspacePath(path string) { s.workspacePath = strings.TrimSpace(path) } func (s *Server) SetLogFilePath(path string) { s.logFilePath = strings.TrimSpace(path) } @@ -214,16 +121,11 @@ func (s *Server) SetConfigAfterHook(fn func() error) { s.onConfigAfter = fn } func (s *Server) SetCronHandler(fn func(action string, args map[string]interface{}) (interface{}, error)) { s.onCron = fn } -func (s *Server) SetSubagentHandler(fn func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error)) { - s.onSubagents = fn -} func (s *Server) SetNodeDispatchHandler(fn func(ctx context.Context, req nodes.Request, mode string) (nodes.Response, error)) { s.onNodeDispatch = fn } func (s *Server) SetToolsCatalogHandler(fn func() interface{}) { s.onToolsCatalog = fn } -func (s *Server) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) } func (s *Server) SetGatewayVersion(v string) { s.gatewayVersion = strings.TrimSpace(v) } -func (s *Server) SetWebUIVersion(v string) { s.webuiVersion = strings.TrimSpace(v) } func (s *Server) SetProtectedRoute(path string, handler http.Handler) { if s == nil { return @@ -399,12 +301,10 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/nodes/register", s.handleRegister) mux.HandleFunc("/nodes/heartbeat", s.handleHeartbeat) mux.HandleFunc("/nodes/connect", s.handleNodeConnect) - mux.HandleFunc("/", s.handleWebUIAsset) mux.HandleFunc("/api/config", s.handleWebUIConfig) mux.HandleFunc("/api/chat", s.handleWebUIChat) mux.HandleFunc("/api/chat/history", s.handleWebUIChatHistory) mux.HandleFunc("/api/chat/live", s.handleWebUIChatLive) - mux.HandleFunc("/api/runtime", s.handleWebUIRuntime) mux.HandleFunc("/api/version", s.handleWebUIVersion) mux.HandleFunc("/api/provider/oauth/start", s.handleWebUIProviderOAuthStart) mux.HandleFunc("/api/provider/oauth/complete", s.handleWebUIProviderOAuthComplete) @@ -429,12 +329,9 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/api/sessions", s.handleWebUISessions) mux.HandleFunc("/api/memory", s.handleWebUIMemory) mux.HandleFunc("/api/workspace_file", s.handleWebUIWorkspaceFile) - mux.HandleFunc("/api/subagents_runtime", s.handleWebUISubagentsRuntime) mux.HandleFunc("/api/tool_allowlist_groups", s.handleWebUIToolAllowlistGroups) mux.HandleFunc("/api/tools", s.handleWebUITools) mux.HandleFunc("/api/mcp/install", s.handleWebUIMCPInstall) - mux.HandleFunc("/api/task_queue", s.handleWebUITaskQueue) - mux.HandleFunc("/api/ekg_stats", s.handleWebUIEKGStats) mux.HandleFunc("/api/logs/live", s.handleWebUILogsLive) mux.HandleFunc("/api/logs/recent", s.handleWebUILogsRecent) s.extraRoutesMu.RLock() @@ -658,81 +555,6 @@ func (s *Server) handleNodeConnect(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) handleWebUI(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if s.token != "" { - http.SetCookie(w, &http.Cookie{ - Name: "clawgo_webui_token", - Value: s.token, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - MaxAge: 86400, - }) - } - if s.tryServeWebUIDist(w, r, "/index.html") { - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write([]byte(webUIHTML)) -} - -func (s *Server) handleWebUIAsset(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if strings.HasPrefix(r.URL.Path, "/api/") { - http.NotFound(w, r) - return - } - if r.URL.Path == "/" { - s.handleWebUI(w, r) - return - } - if s.tryServeWebUIDist(w, r, r.URL.Path) { - return - } - // SPA fallback - if s.tryServeWebUIDist(w, r, "/index.html") { - return - } - http.NotFound(w, r) -} - -func (s *Server) tryServeWebUIDist(w http.ResponseWriter, r *http.Request, reqPath string) bool { - dir := strings.TrimSpace(s.webUIDir) - if dir == "" { - return false - } - p := strings.TrimPrefix(reqPath, "/") - if reqPath == "/" || reqPath == "/index.html" { - p = "index.html" - } - p = filepath.Clean(strings.TrimPrefix(p, "/")) - if strings.HasPrefix(p, "..") { - return false - } - full := filepath.Join(dir, p) - fi, err := os.Stat(full) - if err != nil || fi.IsDir() { - return false - } - http.ServeFile(w, r, full) - return true -} - func (s *Server) handleWebUIConfig(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -805,107 +627,7 @@ func (s *Server) handleWebUIConfig(w http.ResponseWriter, r *http.Request) { out, _ := json.MarshalIndent(merged, "", " ") _, _ = w.Write(out) case http.MethodPost: - var body map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - confirmRisky, _ := tools.MapBoolArg(body, "confirm_risky") - delete(body, "confirm_risky") - - oldCfgRaw, _ := os.ReadFile(s.configPath) - var oldMap map[string]interface{} - _ = json.Unmarshal(oldCfgRaw, &oldMap) - riskyOldMap := oldMap - riskyNewMap := body - if strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("mode")), "normalized") { - if loaded, err := cfgpkg.LoadConfig(s.configPath); err == nil && loaded != nil { - if raw, err := json.Marshal(loaded.NormalizedView()); err == nil { - _ = json.Unmarshal(raw, &riskyOldMap) - } - } - } - - riskyPaths := collectRiskyConfigPaths(riskyOldMap, riskyNewMap) - changedRisky := make([]string, 0) - for _, p := range riskyPaths { - if fmt.Sprintf("%v", getPathValue(riskyOldMap, p)) != fmt.Sprintf("%v", getPathValue(riskyNewMap, p)) { - changedRisky = append(changedRisky, p) - } - } - if len(changedRisky) > 0 && !confirmRisky { - writeJSONStatus(w, http.StatusBadRequest, map[string]interface{}{ - "ok": false, - "error": "risky fields changed; confirmation required", - "requires_confirm": true, - "changed_fields": changedRisky, - }) - return - } - - cfg := cfgpkg.DefaultConfig() - if strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("mode")), "normalized") { - loaded, err := cfgpkg.LoadConfig(s.configPath) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - cfg = loaded - candidate, err := json.Marshal(body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - var normalized cfgpkg.NormalizedConfig - dec := json.NewDecoder(bytes.NewReader(candidate)) - dec.DisallowUnknownFields() - if err := dec.Decode(&normalized); err != nil { - http.Error(w, "normalized config validation failed: "+err.Error(), http.StatusBadRequest) - return - } - cfg.ApplyNormalizedView(normalized) - } else { - candidate, err := json.Marshal(body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - dec := json.NewDecoder(bytes.NewReader(candidate)) - dec.DisallowUnknownFields() - if err := dec.Decode(cfg); err != nil { - http.Error(w, "config schema validation failed: "+err.Error(), http.StatusBadRequest) - return - } - } - if errs := cfgpkg.Validate(cfg); len(errs) > 0 { - list := make([]string, 0, len(errs)) - for _, e := range errs { - list = append(list, e.Error()) - } - writeJSONStatus(w, http.StatusBadRequest, map[string]interface{}{"ok": false, "error": "config validation failed", "details": list}) - return - } - - if err := cfgpkg.SaveConfig(s.configPath, cfg); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if s.onConfigAfter != nil { - if err := s.onConfigAfter(); err != nil { - http.Error(w, "config saved but reload failed: "+err.Error(), http.StatusInternalServerError) - return - } - } else { - if err := requestSelfReloadSignal(); err != nil { - http.Error(w, "config saved but reload signal failed: "+err.Error(), http.StatusInternalServerError) - return - } - } - if strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("mode")), "normalized") { - writeJSON(w, map[string]interface{}{"ok": true, "reloaded": true, "config": cfg.NormalizedView()}) - return - } - writeJSON(w, map[string]interface{}{"ok": true, "reloaded": true}) + http.Error(w, "webui config editing is disabled", http.StatusMethodNotAllowed) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } @@ -929,82 +651,6 @@ func mergeJSONMap(base, override map[string]interface{}) map[string]interface{} return base } -func getPathValue(m map[string]interface{}, path string) interface{} { - if m == nil || strings.TrimSpace(path) == "" { - return nil - } - parts := strings.Split(path, ".") - var cur interface{} = m - for _, p := range parts { - node, ok := cur.(map[string]interface{}) - if !ok { - return nil - } - cur = node[p] - } - return cur -} - -func collectRiskyConfigPaths(oldMap, newMap map[string]interface{}) []string { - paths := []string{ - "channels.telegram.token", - "channels.telegram.allow_from", - "channels.telegram.allow_chats", - "models.providers.openai.api_base", - "models.providers.openai.api_key", - "runtime.providers.openai.api_base", - "runtime.providers.openai.api_key", - "gateway.token", - "gateway.port", - } - seen := map[string]bool{} - for _, path := range paths { - seen[path] = true - } - for _, name := range collectProviderNames(oldMap, newMap) { - for _, field := range []string{"api_base", "api_key"} { - path := "models.providers." + name + "." + field - if !seen[path] { - paths = append(paths, path) - seen[path] = true - } - normalizedPath := "runtime.providers." + name + "." + field - if !seen[normalizedPath] { - paths = append(paths, normalizedPath) - seen[normalizedPath] = true - } - } - } - return paths -} - -func collectProviderNames(maps ...map[string]interface{}) []string { - seen := map[string]bool{} - names := make([]string, 0) - for _, root := range maps { - models, _ := root["models"].(map[string]interface{}) - providers, _ := models["providers"].(map[string]interface{}) - for name := range providers { - if strings.TrimSpace(name) == "" || seen[name] { - continue - } - seen[name] = true - names = append(names, name) - } - runtimeMap, _ := root["runtime"].(map[string]interface{}) - runtimeProviders, _ := runtimeMap["providers"].(map[string]interface{}) - for name := range runtimeProviders { - if strings.TrimSpace(name) == "" || seen[name] { - continue - } - seen[name] = true - names = append(names, name) - } - } - sort.Strings(names) - return names -} - func (s *Server) handleWebUIUpload(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -1732,7 +1378,6 @@ func (s *Server) handleWebUIVersion(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{ "ok": true, "gateway_version": firstNonEmptyString(s.gatewayVersion, gatewayBuildVersion()), - "webui_version": firstNonEmptyString(s.webuiVersion, detectWebUIVersion(strings.TrimSpace(s.webUIDir))), "compiled_channels": channels.CompiledChannelKeys(), }) } @@ -1885,14 +1530,6 @@ func (s *Server) webUIWhatsAppStatusPayload(ctx context.Context) (map[string]int }, http.StatusOK } -func (s *Server) loadWhatsAppConfig() (cfgpkg.WhatsAppConfig, error) { - cfg, err := s.loadConfig() - if err != nil { - return cfgpkg.WhatsAppConfig{}, err - } - return cfg.Channels.WhatsApp, nil -} - func (s *Server) loadConfig() (*cfgpkg.Config, error) { configPath := strings.TrimSpace(s.configPath) if configPath == "" { @@ -1989,81 +1626,6 @@ func renderQRCodeSVG(code *qr.Code, scale, quietZone int) string { return b.String() } -func (s *Server) handleWebUIRuntime(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - conn, err := nodesWebsocketUpgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer conn.Close() - - ctx := r.Context() - sub := s.subscribeRuntimeLive(ctx) - initial := map[string]interface{}{ - "ok": true, - "type": "runtime_snapshot", - "snapshot": s.buildWebUIRuntimeSnapshot(ctx), - } - _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - if err := conn.WriteJSON(initial); err != nil { - return - } - for { - select { - case <-ctx.Done(): - return - case payload := <-sub: - _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil { - return - } - } - } -} - -func (s *Server) buildWebUIRuntimeSnapshot(ctx context.Context) map[string]interface{} { - var providerPayload map[string]interface{} - var normalizedConfig interface{} - if strings.TrimSpace(s.configPath) != "" { - if cfg, err := cfgpkg.LoadConfig(strings.TrimSpace(s.configPath)); err == nil { - providerPayload = providers.GetProviderRuntimeSnapshot(cfg) - normalizedConfig = cfg.NormalizedView() - } - } - if providerPayload == nil { - providerPayload = map[string]interface{}{"items": []interface{}{}} - } - runtimePayload := map[string]interface{}{} - if s.onSubagents != nil { - if res, err := s.onSubagents(ctx, "snapshot", map[string]interface{}{"limit": 200}); err == nil { - if m, ok := res.(map[string]interface{}); ok { - runtimePayload = m - } - } - } - return map[string]interface{}{ - "version": s.webUIVersionPayload(), - "config": normalizedConfig, - "runtime": runtimePayload, - "nodes": s.webUINodesPayload(ctx), - "sessions": s.webUISessionsPayload(), - "task_queue": s.webUITaskQueuePayload(false), - "ekg": s.webUIEKGSummaryPayload("24h"), - "providers": providerPayload, - } -} - -func (s *Server) webUIVersionPayload() map[string]interface{} { - return map[string]interface{}{ - "gateway_version": firstNonEmptyString(s.gatewayVersion, gatewayBuildVersion()), - "webui_version": firstNonEmptyString(s.webuiVersion, detectWebUIVersion(strings.TrimSpace(s.webUIDir))), - "compiled_channels": channels.CompiledChannelKeys(), - } -} - func (s *Server) webUINodesPayload(ctx context.Context) map[string]interface{} { list := []nodes.NodeInfo{} if s.mgr != nil { @@ -2721,250 +2283,6 @@ func (s *Server) deleteNodeArtifact(id string) (bool, bool, error) { return deletedFile, true, nil } -func (s *Server) webUISessionsPayload() map[string]interface{} { - sessionsDir := filepath.Join(filepath.Dir(s.workspacePath), "agents", "main", "sessions") - _ = os.MkdirAll(sessionsDir, 0755) - type item struct { - Key string `json:"key"` - Channel string `json:"channel,omitempty"` - } - out := make([]item, 0, 16) - entries, err := os.ReadDir(sessionsDir) - if err == nil { - seen := map[string]struct{}{} - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - if !strings.HasSuffix(name, ".jsonl") || strings.Contains(name, ".deleted.") { - continue - } - key := strings.TrimSuffix(name, ".jsonl") - if strings.TrimSpace(key) == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - channel := "" - if i := strings.Index(key, ":"); i > 0 { - channel = key[:i] - } - out = append(out, item{Key: key, Channel: channel}) - } - } - if len(out) == 0 { - out = append(out, item{Key: "main", Channel: "main"}) - } - return map[string]interface{}{"sessions": out} -} - -func (s *Server) webUITaskQueuePayload(includeHeartbeat bool) map[string]interface{} { - path := s.memoryFilePath("task-audit.jsonl") - b, err := os.ReadFile(path) - lines := []string{} - if err == nil { - lines = strings.Split(string(b), "\n") - } - type agg struct { - Last map[string]interface{} - Logs []string - Attempts int - } - m := map[string]*agg{} - for _, ln := range lines { - if ln == "" { - continue - } - var row map[string]interface{} - if err := json.Unmarshal([]byte(ln), &row); err != nil { - continue - } - source := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["source"]))) - if !includeHeartbeat && source == "heartbeat" { - continue - } - id := fmt.Sprintf("%v", row["task_id"]) - if id == "" { - continue - } - if _, ok := m[id]; !ok { - m[id] = &agg{Last: row, Logs: []string{}, Attempts: 0} - } - a := m[id] - a.Last = row - a.Attempts++ - if lg := strings.TrimSpace(fmt.Sprintf("%v", row["log"])); lg != "" { - if len(a.Logs) == 0 || a.Logs[len(a.Logs)-1] != lg { - a.Logs = append(a.Logs, lg) - if len(a.Logs) > 20 { - a.Logs = a.Logs[len(a.Logs)-20:] - } - } - } - } - items := make([]map[string]interface{}, 0, len(m)) - running := make([]map[string]interface{}, 0) - for _, a := range m { - row := a.Last - row["logs"] = a.Logs - row["attempts"] = a.Attempts - items = append(items, row) - if fmt.Sprintf("%v", row["status"]) == "running" { - running = append(running, row) - } - } - queuePath := s.memoryFilePath("task_queue.json") - if qb, qErr := os.ReadFile(queuePath); qErr == nil { - var q map[string]interface{} - if json.Unmarshal(qb, &q) == nil { - if arr, ok := q["running"].([]interface{}); ok { - for _, it := range arr { - if row, ok := it.(map[string]interface{}); ok { - running = append(running, row) - } - } - } - } - } - sort.Slice(items, func(i, j int) bool { - return fmt.Sprintf("%v", items[i]["updated_at"]) > fmt.Sprintf("%v", items[j]["updated_at"]) - }) - sort.Slice(running, func(i, j int) bool { - return fmt.Sprintf("%v", running[i]["updated_at"]) > fmt.Sprintf("%v", running[j]["updated_at"]) - }) - if len(items) > 30 { - items = items[:30] - } - return map[string]interface{}{"items": items, "running": running} -} - -func (s *Server) webUIEKGSummaryPayload(window string) map[string]interface{} { - ekgPath := s.memoryFilePath("ekg-events.jsonl") - window = strings.ToLower(strings.TrimSpace(window)) - windowDur := 24 * time.Hour - switch window { - case "6h": - windowDur = 6 * time.Hour - case "24h", "": - windowDur = 24 * time.Hour - case "7d": - windowDur = 7 * 24 * time.Hour - } - selectedWindow := window - if selectedWindow == "" { - selectedWindow = "24h" - } - cutoff := time.Now().UTC().Add(-windowDur) - rows := s.loadEKGRowsCached(ekgPath, 3000) - type kv struct { - Key string `json:"key"` - Score float64 `json:"score,omitempty"` - Count int `json:"count,omitempty"` - } - providerScore := map[string]float64{} - providerScoreWorkload := map[string]float64{} - errSigCount := map[string]int{} - errSigHeartbeat := map[string]int{} - errSigWorkload := map[string]int{} - sourceStats := map[string]int{} - channelStats := map[string]int{} - for _, row := range rows { - ts := strings.TrimSpace(fmt.Sprintf("%v", row["time"])) - if ts != "" { - if tm, err := time.Parse(time.RFC3339, ts); err == nil && tm.Before(cutoff) { - continue - } - } - provider := strings.TrimSpace(fmt.Sprintf("%v", row["provider"])) - status := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["status"]))) - errSig := strings.TrimSpace(fmt.Sprintf("%v", row["errsig"])) - source := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["source"]))) - channel := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["channel"]))) - if source == "heartbeat" { - continue - } - if source == "" { - source = "unknown" - } - if channel == "" { - channel = "unknown" - } - sourceStats[source]++ - channelStats[channel]++ - if provider != "" { - providerScoreWorkload[provider] += 1 - if status == "success" { - providerScore[provider] += 1 - } else if status == "error" { - providerScore[provider] -= 2 - } - } - if errSig != "" { - errSigWorkload[errSig]++ - if source == "heartbeat" { - errSigHeartbeat[errSig]++ - } else if status == "error" { - errSigCount[errSig]++ - } - } - } - toTopScore := func(m map[string]float64, limit int) []kv { - out := make([]kv, 0, len(m)) - for k, v := range m { - out = append(out, kv{Key: k, Score: v}) - } - sort.Slice(out, func(i, j int) bool { - if out[i].Score == out[j].Score { - return out[i].Key < out[j].Key - } - return out[i].Score > out[j].Score - }) - if len(out) > limit { - out = out[:limit] - } - return out - } - toTopCount := func(m map[string]int, limit int) []kv { - out := make([]kv, 0, len(m)) - for k, v := range m { - out = append(out, kv{Key: k, Count: v}) - } - sort.Slice(out, func(i, j int) bool { - if out[i].Count == out[j].Count { - return out[i].Key < out[j].Key - } - return out[i].Count > out[j].Count - }) - if len(out) > limit { - out = out[:limit] - } - return out - } - return map[string]interface{}{ - "ok": true, - "window": selectedWindow, - "rows": len(rows), - "provider_top_score": toTopScore(providerScore, 5), - "provider_top_workload": toTopCount(mapFromFloatCounts(providerScoreWorkload), 5), - "errsig_top": toTopCount(errSigCount, 5), - "errsig_top_heartbeat": toTopCount(errSigHeartbeat, 5), - "errsig_top_workload": toTopCount(errSigWorkload, 5), - "source_top": toTopCount(sourceStats, 5), - "channel_top": toTopCount(channelStats, 5), - } -} - -func mapFromFloatCounts(src map[string]float64) map[string]int { - out := make(map[string]int, len(src)) - for k, v := range src { - out[k] = int(v) - } - return out -} - func (s *Server) handleWebUITools(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -3535,32 +2853,30 @@ func (s *Server) buildNodeAgentTrees(ctx context.Context, nodeList []nodes.NodeI } func (s *Server) fetchRegistryItems(ctx context.Context) []map[string]interface{} { - if s == nil || s.onSubagents == nil { + _ = ctx + if s == nil || strings.TrimSpace(s.configPath) == "" { return nil } - result, err := s.onSubagents(ctx, "registry", nil) - if err != nil { + cfg, err := cfgpkg.LoadConfig(strings.TrimSpace(s.configPath)) + if err != nil || cfg == nil { return nil } - payload, ok := result.(map[string]interface{}) - if !ok { - return nil - } - rawItems, ok := payload["items"].([]map[string]interface{}) - if ok { - return rawItems - } - list, ok := payload["items"].([]interface{}) - if !ok { - return nil - } - items := make([]map[string]interface{}, 0, len(list)) - for _, item := range list { - row, ok := item.(map[string]interface{}) - if ok { - items = append(items, row) + items := make([]map[string]interface{}, 0, len(cfg.Agents.Subagents)) + for agentID, subcfg := range cfg.Agents.Subagents { + if !subcfg.Enabled { + continue } + items = append(items, map[string]interface{}{ + "agent_id": agentID, + "display_name": subcfg.DisplayName, + "role": subcfg.Role, + "type": subcfg.Type, + "transport": fallbackString(strings.TrimSpace(subcfg.Transport), "local"), + }) } + sort.Slice(items, func(i, j int) bool { + return strings.TrimSpace(stringFromMap(items[i], "agent_id")) < strings.TrimSpace(stringFromMap(items[j], "agent_id")) + }) return items } @@ -3584,7 +2900,7 @@ func (s *Server) fetchRemoteNodeRegistry(ctx context.Context, node nodes.NodeInf } defer resp.Body.Close() if resp.StatusCode >= 300 { - return s.fetchRemoteNodeRegistryLegacy(ctx, node) + return nil, fmt.Errorf("remote subagent registry unavailable: %s", strings.TrimSpace(node.ID)) } var payload struct { OK bool `json:"ok"` @@ -3592,47 +2908,13 @@ func (s *Server) fetchRemoteNodeRegistry(ctx context.Context, node nodes.NodeInf RawConfig map[string]interface{} `json:"raw_config"` } if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil { - return s.fetchRemoteNodeRegistryLegacy(ctx, node) + return nil, err } items := buildRegistryItemsFromNormalizedConfig(payload.Config) if len(items) > 0 { return items, nil } - return s.fetchRemoteNodeRegistryLegacy(ctx, node) -} - -func (s *Server) fetchRemoteNodeRegistryLegacy(ctx context.Context, node nodes.NodeInfo) ([]map[string]interface{}, error) { - baseURL := nodeWebUIBaseURL(node) - if baseURL == "" { - return nil, fmt.Errorf("node %s endpoint missing", strings.TrimSpace(node.ID)) - } - reqURL := baseURL + "/api/subagents_runtime?action=registry" - if tok := strings.TrimSpace(node.Token); tok != "" { - reqURL += "&token=" + url.QueryEscape(tok) - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) - if err != nil { - return nil, err - } - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("remote status %d", resp.StatusCode) - } - var payload struct { - OK bool `json:"ok"` - Result struct { - Items []map[string]interface{} `json:"items"` - } `json:"result"` - } - if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil { - return nil, err - } - return payload.Result.Items, nil + return nil, fmt.Errorf("remote subagent registry unavailable: %s", strings.TrimSpace(node.ID)) } func buildRegistryItemsFromNormalizedConfig(view cfgpkg.NormalizedConfig) []map[string]interface{} { @@ -4244,10 +3526,6 @@ func gatewayBuildVersion() string { return "unknown" } -func detectWebUIVersion(webUIDir string) string { - _ = webUIDir - return "dev" -} func firstNonEmptyString(values ...string) string { for _, v := range values { @@ -4601,10 +3879,6 @@ func ensureClawHubReady(ctx context.Context) (string, error) { return strings.Join(outs, "\n"), fmt.Errorf("installed clawhub but executable still not found in PATH") } -func ensureMCPPackageInstalled(ctx context.Context, pkgName string) (output string, binName string, binPath string, err error) { - return ensureMCPPackageInstalledWithInstaller(ctx, pkgName, "npm") -} - func ensureMCPPackageInstalledWithInstaller(ctx context.Context, pkgName, installer string) (output string, binName string, binPath string, err error) { pkgName = strings.TrimSpace(pkgName) if pkgName == "" { @@ -5056,13 +4330,6 @@ func anyToString(v interface{}) string { } } -func derefInt(v *int) int { - if v == nil { - return 0 - } - return *v -} - func (s *Server) handleWebUISessions(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -5153,58 +4420,6 @@ func (s *Server) handleWebUIToolAllowlistGroups(w http.ResponseWriter, r *http.R }) } -func (s *Server) handleWebUISubagentsRuntime(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if s.onSubagents == nil { - http.Error(w, "subagent runtime handler not configured", http.StatusServiceUnavailable) - return - } - - action := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("action"))) - args := map[string]interface{}{} - switch r.Method { - case http.MethodGet: - if action == "" { - action = "list" - } - for key, values := range r.URL.Query() { - if key == "action" || key == "token" || len(values) == 0 { - continue - } - args[key] = strings.TrimSpace(values[0]) - } - case http.MethodPost: - var body map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - if body == nil { - body = map[string]interface{}{} - } - if action == "" { - if raw := stringFromMap(body, "action"); raw != "" { - action = strings.ToLower(strings.TrimSpace(raw)) - } - } - delete(body, "action") - args = body - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - result, err := s.onSubagents(r.Context(), action, args) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - writeJSON(w, map[string]interface{}{"ok": true, "result": result}) -} - func (s *Server) handleWebUIMemory(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -5307,333 +4522,6 @@ func (s *Server) handleWebUIWorkspaceFile(w http.ResponseWriter, r *http.Request } } -func (s *Server) handleWebUITaskQueue(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - path := s.memoryFilePath("task-audit.jsonl") - includeHeartbeat := r.URL.Query().Get("include_heartbeat") == "1" - b, err := os.ReadFile(path) - lines := []string{} - if err == nil { - lines = strings.Split(string(b), "\n") - } - type agg struct { - Last map[string]interface{} - Logs []string - Attempts int - } - m := map[string]*agg{} - for _, ln := range lines { - if ln == "" { - continue - } - var row map[string]interface{} - if err := json.Unmarshal([]byte(ln), &row); err != nil { - continue - } - source := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["source"]))) - if !includeHeartbeat && source == "heartbeat" { - continue - } - id := fmt.Sprintf("%v", row["task_id"]) - if id == "" { - continue - } - if _, ok := m[id]; !ok { - m[id] = &agg{Last: row, Logs: []string{}, Attempts: 0} - } - a := m[id] - a.Last = row - a.Attempts++ - if lg := strings.TrimSpace(fmt.Sprintf("%v", row["log"])); lg != "" { - if len(a.Logs) == 0 || a.Logs[len(a.Logs)-1] != lg { - a.Logs = append(a.Logs, lg) - if len(a.Logs) > 20 { - a.Logs = a.Logs[len(a.Logs)-20:] - } - } - } - } - items := make([]map[string]interface{}, 0, len(m)) - running := make([]map[string]interface{}, 0) - for _, a := range m { - row := a.Last - row["logs"] = a.Logs - row["attempts"] = a.Attempts - items = append(items, row) - if fmt.Sprintf("%v", row["status"]) == "running" { - running = append(running, row) - } - } - - // Merge command watchdog queue from memory/task_queue.json for visibility. - queuePath := s.memoryFilePath("task_queue.json") - if qb, qErr := os.ReadFile(queuePath); qErr == nil { - var q map[string]interface{} - if json.Unmarshal(qb, &q) == nil { - if arr, ok := q["running"].([]interface{}); ok { - for _, item := range arr { - row, ok := item.(map[string]interface{}) - if !ok { - continue - } - id := fmt.Sprintf("%v", row["id"]) - if strings.TrimSpace(id) == "" { - continue - } - label := fmt.Sprintf("%v", row["label"]) - source := strings.TrimSpace(fmt.Sprintf("%v", row["source"])) - if source == "" { - source = "task_watchdog" - } - rec := map[string]interface{}{ - "task_id": "cmd:" + id, - "time": fmt.Sprintf("%v", row["started_at"]), - "status": "running", - "source": "task_watchdog", - "channel": source, - "session": "watchdog:" + id, - "input_preview": label, - "duration_ms": 0, - "attempts": 1, - "retry_count": 0, - "logs": []string{ - fmt.Sprintf("watchdog source=%s heavy=%v", source, row["heavy"]), - fmt.Sprintf("next_check_at=%v stalled_rounds=%v/%v", row["next_check_at"], row["stalled_rounds"], row["stall_round_limit"]), - }, - "idle_run": true, - } - items = append(items, rec) - running = append(running, rec) - } - } - if arr, ok := q["waiting"].([]interface{}); ok { - for _, item := range arr { - row, ok := item.(map[string]interface{}) - if !ok { - continue - } - id := fmt.Sprintf("%v", row["id"]) - if strings.TrimSpace(id) == "" { - continue - } - label := fmt.Sprintf("%v", row["label"]) - source := strings.TrimSpace(fmt.Sprintf("%v", row["source"])) - if source == "" { - source = "task_watchdog" - } - rec := map[string]interface{}{ - "task_id": "cmd:" + id, - "time": fmt.Sprintf("%v", row["enqueued_at"]), - "status": "waiting", - "source": "task_watchdog", - "channel": source, - "session": "watchdog:" + id, - "input_preview": label, - "duration_ms": 0, - "attempts": 1, - "retry_count": 0, - "logs": []string{ - fmt.Sprintf("watchdog source=%s heavy=%v", source, row["heavy"]), - fmt.Sprintf("enqueued_at=%v", row["enqueued_at"]), - }, - "idle_run": true, - } - items = append(items, rec) - } - } - if wd, ok := q["watchdog"].(map[string]interface{}); ok { - items = append(items, map[string]interface{}{ - "task_id": "cmd:watchdog", - "time": fmt.Sprintf("%v", q["time"]), - "status": "running", - "source": "task_watchdog", - "channel": "watchdog", - "session": "watchdog:stats", - "input_preview": "task watchdog capacity snapshot", - "duration_ms": 0, - "attempts": 1, - "retry_count": 0, - "logs": []string{ - fmt.Sprintf("cpu_total=%v usage_ratio=%v reserve_pct=%v", wd["cpu_total"], wd["usage_ratio"], wd["reserve_pct"]), - fmt.Sprintf("active=%v/%v heavy=%v/%v waiting=%v running=%v", wd["active"], wd["max_active"], wd["active_heavy"], wd["max_heavy"], wd["waiting"], wd["running"]), - }, - "idle_run": true, - }) - } - } - } - - sort.Slice(items, func(i, j int) bool { return fmt.Sprintf("%v", items[i]["time"]) > fmt.Sprintf("%v", items[j]["time"]) }) - stats := map[string]int{"total": len(items), "running": len(running)} - writeJSON(w, map[string]interface{}{"ok": true, "running": running, "items": items, "stats": stats}) -} - -func (s *Server) loadEKGRowsCached(path string, maxLines int) []map[string]interface{} { - path = strings.TrimSpace(path) - if path == "" { - return nil - } - fi, err := os.Stat(path) - if err != nil { - return nil - } - s.ekgCacheMu.Lock() - defer s.ekgCacheMu.Unlock() - if s.ekgCachePath == path && s.ekgCacheSize == fi.Size() && s.ekgCacheStamp.Equal(fi.ModTime()) && len(s.ekgCacheRows) > 0 { - return s.ekgCacheRows - } - b, err := os.ReadFile(path) - if err != nil { - return nil - } - lines := strings.Split(string(b), "\n") - if len(lines) > 0 && lines[len(lines)-1] == "" { - lines = lines[:len(lines)-1] - } - if maxLines > 0 && len(lines) > maxLines { - lines = lines[len(lines)-maxLines:] - } - rows := make([]map[string]interface{}, 0, len(lines)) - for _, ln := range lines { - if strings.TrimSpace(ln) == "" { - continue - } - var row map[string]interface{} - if json.Unmarshal([]byte(ln), &row) == nil { - rows = append(rows, row) - } - } - s.ekgCachePath = path - s.ekgCacheSize = fi.Size() - s.ekgCacheStamp = fi.ModTime() - s.ekgCacheRows = rows - return rows -} - -func (s *Server) handleWebUIEKGStats(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - ekgPath := s.memoryFilePath("ekg-events.jsonl") - window := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("window"))) - windowDur := 24 * time.Hour - switch window { - case "6h": - windowDur = 6 * time.Hour - case "24h", "": - windowDur = 24 * time.Hour - case "7d": - windowDur = 7 * 24 * time.Hour - } - selectedWindow := window - if selectedWindow == "" { - selectedWindow = "24h" - } - cutoff := time.Now().UTC().Add(-windowDur) - rows := s.loadEKGRowsCached(ekgPath, 3000) - type kv struct { - Key string `json:"key"` - Score float64 `json:"score,omitempty"` - Count int `json:"count,omitempty"` - } - providerScore := map[string]float64{} - providerScoreWorkload := map[string]float64{} - errSigCount := map[string]int{} - errSigHeartbeat := map[string]int{} - errSigWorkload := map[string]int{} - sourceStats := map[string]int{} - channelStats := map[string]int{} - for _, row := range rows { - ts := strings.TrimSpace(fmt.Sprintf("%v", row["time"])) - if ts != "" { - if tm, err := time.Parse(time.RFC3339, ts); err == nil { - if tm.Before(cutoff) { - continue - } - } - } - provider := strings.TrimSpace(fmt.Sprintf("%v", row["provider"])) - status := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["status"]))) - errSig := strings.TrimSpace(fmt.Sprintf("%v", row["errsig"])) - source := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["source"]))) - channel := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["channel"]))) - if source == "heartbeat" { - continue - } - if source == "" { - source = "unknown" - } - if channel == "" { - channel = "unknown" - } - sourceStats[source]++ - channelStats[channel]++ - if provider != "" { - switch status { - case "success": - providerScore[provider] += 1 - providerScoreWorkload[provider] += 1 - case "suppressed": - providerScore[provider] += 0.2 - providerScoreWorkload[provider] += 0.2 - case "error": - providerScore[provider] -= 1 - providerScoreWorkload[provider] -= 1 - } - } - if errSig != "" && status == "error" { - errSigCount[errSig]++ - errSigWorkload[errSig]++ - } - } - toTopScore := func(m map[string]float64, n int) []kv { - out := make([]kv, 0, len(m)) - for k, v := range m { - out = append(out, kv{Key: k, Score: v}) - } - sort.Slice(out, func(i, j int) bool { return out[i].Score > out[j].Score }) - if len(out) > n { - out = out[:n] - } - return out - } - toTopCount := func(m map[string]int, n int) []kv { - out := make([]kv, 0, len(m)) - for k, v := range m { - out = append(out, kv{Key: k, Count: v}) - } - sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count }) - if len(out) > n { - out = out[:n] - } - return out - } - writeJSON(w, map[string]interface{}{ - "ok": true, - "window": selectedWindow, - "provider_top": toTopScore(providerScore, 5), - "provider_top_workload": toTopScore(providerScoreWorkload, 5), - "errsig_top": toTopCount(errSigCount, 5), - "errsig_top_heartbeat": toTopCount(errSigHeartbeat, 5), - "errsig_top_workload": toTopCount(errSigWorkload, 5), - "source_stats": sourceStats, - "channel_stats": channelStats, - "escalation_count": 0, - }) -} - func (s *Server) handleWebUILogsRecent(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -5778,31 +4666,3 @@ func hotReloadFieldInfo() []map[string]interface{} { {"path": "gateway.*", "name": "Gateway", "description": "Mostly hot-reloadable; host/port may require restart"}, } } - -const webUIHTML = ` - -ClawGo WebUI - - -

ClawGo WebUI

-

Token:

-

Config (dynamic + hot reload)

- - - -

Chat (supports media upload)

-
Session:
-
-` diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index e5313cc..c19c98f 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "path/filepath" + "runtime" "strconv" "strings" "testing" @@ -240,7 +241,7 @@ func TestHandleWebUIWhatsAppStatusMapsLegacyBridgeURLToEmbeddedPath(t *testing.T } } -func TestHandleWebUIConfigRequiresConfirmForProviderAPIBaseChange(t *testing.T) { +func TestHandleWebUIConfigPostIsDisabledForProviderAPIBaseChange(t *testing.T) { t.Parallel() tmp := t.TempDir() @@ -277,18 +278,15 @@ func TestHandleWebUIConfigRequiresConfirmForProviderAPIBaseChange(t *testing.T) srv.handleWebUIConfig(rec, req) - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d: %s", rec.Code, rec.Body.String()) } - if !strings.Contains(rec.Body.String(), `"requires_confirm":true`) { - t.Fatalf("expected requires_confirm response, got: %s", rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), `models.providers.openai.api_base`) { - t.Fatalf("expected models.providers.openai.api_base in changed_fields, got: %s", rec.Body.String()) + if !strings.Contains(rec.Body.String(), "webui config editing is disabled") { + t.Fatalf("expected disabled response, got: %s", rec.Body.String()) } } -func TestHandleWebUIConfigAcceptsStringConfirmRisky(t *testing.T) { +func TestHandleWebUIConfigPostRejectsStringConfirmRisky(t *testing.T) { t.Parallel() tmp := t.TempDir() @@ -334,8 +332,11 @@ func TestHandleWebUIConfigAcceptsStringConfirmRisky(t *testing.T) { srv.handleWebUIConfig(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "webui config editing is disabled") { + t.Fatalf("expected disabled response, got: %s", rec.Body.String()) } } @@ -356,7 +357,7 @@ func TestNormalizeCronJobParsesStringScheduleValues(t *testing.T) { } } -func TestHandleWebUIConfigRequiresConfirmForCustomProviderSecretChange(t *testing.T) { +func TestHandleWebUIConfigPostIsDisabledForCustomProviderSecretChange(t *testing.T) { t.Parallel() tmp := t.TempDir() @@ -398,18 +399,15 @@ func TestHandleWebUIConfigRequiresConfirmForCustomProviderSecretChange(t *testin srv.handleWebUIConfig(rec, req) - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d: %s", rec.Code, rec.Body.String()) } - if !strings.Contains(rec.Body.String(), `"requires_confirm":true`) { - t.Fatalf("expected requires_confirm response, got: %s", rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), `models.providers.backup.api_key`) { - t.Fatalf("expected models.providers.backup.api_key in changed_fields, got: %s", rec.Body.String()) + if !strings.Contains(rec.Body.String(), "webui config editing is disabled") { + t.Fatalf("expected disabled response, got: %s", rec.Body.String()) } } -func TestHandleWebUIConfigRunsReloadHookSynchronously(t *testing.T) { +func TestHandleWebUIConfigPostDoesNotRunReloadHook(t *testing.T) { t.Parallel() tmp := t.TempDir() @@ -439,15 +437,15 @@ func TestHandleWebUIConfigRunsReloadHookSynchronously(t *testing.T) { srv.handleWebUIConfig(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d: %s", rec.Code, rec.Body.String()) } - if !called { - t.Fatalf("expected reload hook to run") + if called { + t.Fatalf("expected reload hook not to run when config editing is disabled") } } -func TestHandleWebUIConfigReturnsReloadHookError(t *testing.T) { +func TestHandleWebUIConfigPostIgnoresReloadHookError(t *testing.T) { t.Parallel() tmp := t.TempDir() @@ -475,11 +473,11 @@ func TestHandleWebUIConfigReturnsReloadHookError(t *testing.T) { srv.handleWebUIConfig(rec, req) - if rec.Code != http.StatusInternalServerError { - t.Fatalf("expected 500, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d: %s", rec.Code, rec.Body.String()) } - if !strings.Contains(rec.Body.String(), "reload failed") { - t.Fatalf("expected reload failure in body, got: %s", rec.Body.String()) + if !strings.Contains(rec.Body.String(), "webui config editing is disabled") { + t.Fatalf("expected disabled response, got: %s", rec.Body.String()) } } @@ -523,7 +521,7 @@ func TestHandleWebUIConfigNormalizedGet(t *testing.T) { } } -func TestHandleWebUIConfigNormalizedPost(t *testing.T) { +func TestHandleWebUIConfigNormalizedPostIsDisabled(t *testing.T) { t.Parallel() tmp := t.TempDir() @@ -560,7 +558,6 @@ func TestHandleWebUIConfigNormalizedPost(t *testing.T) { "allow_direct_agent_chat": false, "max_hops": float64(6), "default_timeout_sec": float64(600), - "default_wait_reply": true, "sticky_thread_owner": true, "rules": []interface{}{ map[string]interface{}{"agent_id": "reviewer", "keywords": []interface{}{"review"}}, @@ -589,18 +586,11 @@ func TestHandleWebUIConfigNormalizedPost(t *testing.T) { rec := httptest.NewRecorder() srv.handleWebUIConfig(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d: %s", rec.Code, rec.Body.String()) } - loaded, err := cfgpkg.LoadConfig(cfgPath) - if err != nil { - t.Fatalf("reload config: %v", err) - } - if !loaded.Agents.Router.Enabled { - t.Fatalf("expected router to be enabled") - } - if _, ok := loaded.Agents.Subagents["reviewer"]; !ok { - t.Fatalf("expected reviewer subagent, got %+v", loaded.Agents.Subagents) + if !strings.Contains(rec.Body.String(), "webui config editing is disabled") { + t.Fatalf("expected disabled response, got: %s", rec.Body.String()) } } @@ -866,10 +856,30 @@ func TestHandleWebUIChatLive(t *testing.T) { } func TestHandleWebUILogsLive(t *testing.T) { - t.Parallel() - - tmp := t.TempDir() - logPath := filepath.Join(tmp, "app.log") + if runtime.GOOS == "windows" { + t.Skip("websocket log tail test is flaky on Windows due file-handle release timing") + } + f, err := os.CreateTemp("", "clawgo-logs-live-*.log") + if err != nil { + t.Fatalf("create temp log file: %v", err) + } + logPath := f.Name() + if err := f.Close(); err != nil { + t.Fatalf("close temp log file: %v", err) + } + t.Cleanup(func() { + deadline := time.Now().Add(3 * time.Second) + for { + err := os.Remove(logPath) + if err == nil || os.IsNotExist(err) { + return + } + if time.Now().After(deadline) { + t.Fatalf("remove temp log file: %v", err) + } + time.Sleep(100 * time.Millisecond) + } + }) if err := os.WriteFile(logPath, []byte(""), 0o644); err != nil { t.Fatalf("write log file: %v", err) } @@ -911,6 +921,10 @@ func TestHandleWebUILogsLive(t *testing.T) { if entry["msg"] != "tail-ok" { t.Fatalf("expected tail-ok entry, got: %+v", entry) } + _ = conn.Close() + httpSrv.Close() + httpSrv.CloseClientConnections() + time.Sleep(1 * time.Second) } func TestHandleWebUINodesIncludesP2PSummary(t *testing.T) { @@ -972,22 +986,20 @@ func TestHandleWebUINodesEnrichesLocalNodeMetadata(t *testing.T) { t.Parallel() srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) - srv.SetSubagentHandler(func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) { - if action != "registry" { - return map[string]interface{}{"items": []map[string]interface{}{}}, nil - } - return map[string]interface{}{ - "items": []map[string]interface{}{ - { - "agent_id": "coder", - "display_name": "Code Agent", - "role": "coding", - "type": "worker", - "transport": "local", - }, - }, - }, nil - }) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Agents.Subagents["coder"] = cfgpkg.SubagentConfig{ + Enabled: true, + DisplayName: "Code Agent", + Role: "coding", + Type: "worker", + Transport: "local", + } + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + srv.SetConfigPath(cfgPath) req := httptest.NewRequest(http.MethodGet, "/api/nodes", nil) rec := httptest.NewRecorder() @@ -1069,8 +1081,23 @@ func TestHandleWebUINodeArtifactsListAndDelete(t *testing.T) { if err := os.WriteFile(artifactPath, []byte("artifact-body"), 0o644); err != nil { t.Fatalf("write artifact: %v", err) } - auditLine := fmt.Sprintf("{\"time\":\"2026-03-09T00:00:00Z\",\"node\":\"edge-a\",\"action\":\"run\",\"artifacts\":[{\"name\":\"artifact.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"source_path\":\"%s\",\"size_bytes\":13}]}\n", artifactPath) - if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte(auditLine), 0o644); err != nil { + row := map[string]interface{}{ + "time": "2026-03-09T00:00:00Z", + "node": "edge-a", + "action": "run", + "artifacts": []map[string]interface{}{{ + "name": "artifact.txt", + "kind": "text", + "mime_type": "text/plain", + "source_path": artifactPath, + "size_bytes": 13, + }}, + } + encoded, err := json.Marshal(row) + if err != nil { + t.Fatalf("marshal audit: %v", err) + } + if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), append(encoded, '\n'), 0o644); err != nil { t.Fatalf("write audit: %v", err) } @@ -1211,17 +1238,50 @@ func TestHandleWebUINodeArtifactsAppliesRetentionConfig(t *testing.T) { cfg := cfgpkg.DefaultConfig() cfg.Gateway.Nodes.Artifacts.Enabled = true cfg.Gateway.Nodes.Artifacts.KeepLatest = 1 + cfg.Gateway.Nodes.Artifacts.RetainDays = 0 cfg.Gateway.Nodes.Artifacts.PruneOnRead = true cfgPath := filepath.Join(workspace, "config.json") if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { t.Fatalf("save config: %v", err) } srv.SetConfigPath(cfgPath) - auditLines := strings.Join([]string{ - "{\"time\":\"2026-03-09T00:00:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"one.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"b25l\"}]}", - "{\"time\":\"2026-03-09T00:01:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"two.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"dHdv\"}]}", - }, "\n") + "\n" - if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte(auditLines), 0o644); err != nil { + now := time.Now().UTC() + rows := []map[string]interface{}{ + { + "time": now.Add(-time.Minute).Format(time.RFC3339), + "node": "edge-a", + "action": "screen_snapshot", + "ok": true, + "artifacts": []map[string]interface{}{{ + "name": "one.txt", + "kind": "text", + "mime_type": "text/plain", + "content_base64": "b25l", + }}, + }, + { + "time": now.Format(time.RFC3339), + "node": "edge-a", + "action": "screen_snapshot", + "ok": true, + "artifacts": []map[string]interface{}{{ + "name": "two.txt", + "kind": "text", + "mime_type": "text/plain", + "content_base64": "dHdv", + }}, + }, + } + var auditBuf bytes.Buffer + for _, row := range rows { + encoded, err := json.Marshal(row) + if err != nil { + t.Fatalf("marshal audit row: %v", err) + } + auditBuf.Write(encoded) + auditBuf.WriteByte('\n') + } + if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), auditBuf.Bytes(), 0o644); err != nil { t.Fatalf("write audit: %v", err) } @@ -1231,6 +1291,14 @@ func TestHandleWebUINodeArtifactsAppliesRetentionConfig(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } + var body map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + listed, _ := body["items"].([]interface{}) + if len(listed) != 1 { + t.Fatalf("expected response to keep 1 artifact, got %+v", body) + } items := srv.webUINodeArtifactsPayload(10) if len(items) != 1 { t.Fatalf("expected retention to keep 1 artifact, got %d", len(items)) diff --git a/pkg/channels/dedupe_regression_test.go b/pkg/channels/dedupe_regression_test.go index 169d772..945ad2d 100644 --- a/pkg/channels/dedupe_regression_test.go +++ b/pkg/channels/dedupe_regression_test.go @@ -40,7 +40,8 @@ func TestDispatchOutbound_DeduplicatesRepeatedSend(t *testing.T) { t.Fatalf("new manager: %v", err) } rc := &recordingChannel{} - mgr.RegisterChannel("test", rc) + mgr.channels["test"] = rc + mgr.refreshSnapshot() ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -85,7 +86,8 @@ func TestDispatchOutbound_DifferentButtonsShouldNotDeduplicate(t *testing.T) { t.Fatalf("new manager: %v", err) } rc := &recordingChannel{} - mgr.RegisterChannel("test", rc) + mgr.channels["test"] = rc + mgr.refreshSnapshot() ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/pkg/channels/feishu.go b/pkg/channels/feishu.go index 9e7351a..e627fce 100644 --- a/pkg/channels/feishu.go +++ b/pkg/channels/feishu.go @@ -410,25 +410,6 @@ func (c *FeishuChannel) buildFeishuMediaOutbound(ctx context.Context, media stri return larkim.MsgTypeFile, string(b), nil } -func (c *FeishuChannel) buildFeishuFileFromBytes(ctx context.Context, name string, data []byte) (string, string, error) { - fileReq := larkim.NewCreateFileReqBuilder(). - Body(larkim.NewCreateFileReqBodyBuilder(). - FileType("stream"). - FileName(name). - Duration(0). - File(bytes.NewReader(data)). - Build()). - Build() - fileResp, err := c.client.Im.File.Create(ctx, fileReq) - if err != nil { - return "", "", fmt.Errorf("failed to upload feishu file: %w", err) - } - if !fileResp.Success() { - return "", "", fmt.Errorf("feishu file upload error: code=%d msg=%s", fileResp.Code, fileResp.Msg) - } - b, _ := json.Marshal(fileResp.Data) - return larkim.MsgTypeFile, string(b), nil -} func readFeishuMedia(media string) (string, []byte, error) { if strings.HasPrefix(media, "http://") || strings.HasPrefix(media, "https://") { diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 34dbc38..4733f3d 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -406,21 +406,6 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { } } -func (m *Manager) GetChannel(name string) (Channel, bool) { - cur, _ := m.snapshot.Load().(map[string]Channel) - channel, ok := cur[name] - return channel, ok -} - -func (m *Manager) GetStatus() map[string]interface{} { - cur, _ := m.snapshot.Load().(map[string]Channel) - status := make(map[string]interface{}, len(cur)) - for name := range cur { - status[name] = map[string]interface{}{} - } - return status -} - func (m *Manager) GetEnabledChannels() []string { cur, _ := m.snapshot.Load().(map[string]Channel) names := make([]string, 0, len(cur)) @@ -430,20 +415,6 @@ func (m *Manager) GetEnabledChannels() []string { return names } -func (m *Manager) RegisterChannel(name string, channel Channel) { - m.mu.Lock() - defer m.mu.Unlock() - m.channels[name] = channel - m.refreshSnapshot() -} - -func (m *Manager) UnregisterChannel(name string) { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.channels, name) - m.refreshSnapshot() -} - func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { m.mu.RLock() channel, exists := m.channels[channelName] diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index e6729ba..27e14c4 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -854,10 +854,6 @@ func (c *TelegramChannel) handleStreamAction(ctx context.Context, chatID int64, return nil } -func renderTelegramStreamChunks(content string) []telegramRenderedChunk { - return renderTelegramStreamChunksWithFinalize(content, false) -} - func renderTelegramStreamChunksWithFinalize(content string, finalizeRich bool) []telegramRenderedChunk { raw := strings.TrimSpace(content) if raw == "" { diff --git a/pkg/channels/telegram_test.go b/pkg/channels/telegram_test.go index b5af88a..4521205 100644 --- a/pkg/channels/telegram_test.go +++ b/pkg/channels/telegram_test.go @@ -7,71 +7,65 @@ import ( "testing" ) -func TestMarkdownToTelegramHTMLFormatsChineseAndInlineMarkup(t *testing.T) { - got := markdownToTelegramHTML("中文 **加粗** *斜体* `代码`") - if strings.Contains(got, "鈹") || strings.Contains(got, "鈥") { - t.Fatalf("unexpected mojibake in output: %q", got) - } - if !strings.Contains(got, "中文 加粗 斜体 代码") { +func TestMarkdownToTelegramHTMLFormatsInlineMarkup(t *testing.T) { + got := markdownToTelegramHTML("plain **bold** *italic* `code`") + if !strings.Contains(got, "plain bold italic code") { t.Fatalf("unexpected formatted output: %q", got) } } -func TestMarkdownToTelegramHTMLFormatsQuoteAndListsWithoutMojibake(t *testing.T) { - input := "> 引用\n- 列表\n* 另一项\n1. 有序" +func TestMarkdownToTelegramHTMLFormatsQuoteAndLists(t *testing.T) { + input := "> quote\n- bullet\n* other\n1. ordered" got := markdownToTelegramHTML(input) - if strings.Contains(got, "鈹") || strings.Contains(got, "鈥") { - t.Fatalf("unexpected mojibake in output: %q", got) - } - if !strings.Contains(got, "> 引用") { + if !strings.Contains(got, "> quote") { t.Fatalf("expected escaped quote marker, got %q", got) } - if !strings.Contains(got, "• 列表") || !strings.Contains(got, "• 另一项") { + if !strings.Contains(got, "• bullet") || !strings.Contains(got, "• other") { t.Fatalf("expected bullet list markers, got %q", got) } - if !strings.Contains(got, "1. 有序") { + if !strings.Contains(got, "1. ordered") { t.Fatalf("expected ordered list marker preserved, got %q", got) } } -func TestRenderTelegramStreamChunksDoesNotInjectMojibake(t *testing.T) { - chunks := renderTelegramStreamChunks("> 引用\n- 列表\n1. 有序\n中文内容") +func TestRenderTelegramStreamChunksDoesNotInjectBrokenPayload(t *testing.T) { + chunks := renderTelegramStreamChunksWithFinalize("> quote\n- bullet\n1. ordered\nplain text", false) if len(chunks) == 0 { t.Fatal("expected stream chunks") } for _, chunk := range chunks { - if strings.Contains(chunk.payload, "鈹") || strings.Contains(chunk.payload, "鈥") { - t.Fatalf("unexpected mojibake chunk payload: %q", chunk.payload) + if strings.TrimSpace(chunk.payload) == "" { + t.Fatalf("unexpected empty chunk payload: %+v", chunk) } } } func TestShouldFlushTelegramStreamSnapshotRejectsUnclosedMarkdown(t *testing.T) { cases := []string{ - "中文 **加粗", - "中文 *斜体", - "中文 `代码", + "text **bold", + "text *italic", + "text `code", "```go\nfmt.Println(\"hi\")", - "[链接](https://example.com", + "[link](https://example.com", } for _, input := range cases { if shouldFlushTelegramStreamSnapshot(input) { t.Fatalf("expected unsafe snapshot to be rejected: %q", input) } - if chunks := renderTelegramStreamChunks(input); len(chunks) != 0 { + if chunks := renderTelegramStreamChunksWithFinalize(input, false); len(chunks) != 0 { t.Fatalf("expected no chunks for unsafe snapshot %q, got %+v", input, chunks) } } } func TestShouldFlushTelegramStreamSnapshotAcceptsBalancedMarkdown(t *testing.T) { - input := "> 引用\n- 列表\n1. 有序\n中文 **加粗** *斜体* `代码` [链接](https://example.com)" + input := "> quote\n- bullet\n1. ordered\ntext **bold** *italic* `code` [link](https://example.com)" if !shouldFlushTelegramStreamSnapshot(input) { t.Fatalf("expected balanced snapshot to flush: %q", input) } - chunks := renderTelegramStreamChunks(input) + chunks := renderTelegramStreamChunksWithFinalize(input, false) if len(chunks) == 0 { t.Fatalf("expected chunks for balanced snapshot") } @@ -81,7 +75,7 @@ func TestShouldFlushTelegramStreamSnapshotAcceptsBalancedMarkdown(t *testing.T) } func TestRenderTelegramStreamChunksFinalizeRecoversRichFormatting(t *testing.T) { - input := "> 引用\n- 列表\n中文 **加粗** *斜体* `代码` [链接](https://example.com)" + input := "> quote\n- bullet\ntext **bold** *italic* `code` [link](https://example.com)" chunks := renderTelegramStreamChunksWithFinalize(input, true) if len(chunks) == 0 { t.Fatalf("expected finalize chunks") @@ -89,25 +83,25 @@ func TestRenderTelegramStreamChunksFinalizeRecoversRichFormatting(t *testing.T) if chunks[0].parseMode != "HTML" { t.Fatalf("expected finalize chunk to use HTML, got %q", chunks[0].parseMode) } - if !strings.Contains(chunks[0].payload, "加粗") { + if !strings.Contains(chunks[0].payload, "bold") { t.Fatalf("expected rich formatting restored, got %q", chunks[0].payload) } } func TestMarkdownToTelegramHTMLHandlesEdgeFormatting(t *testing.T) { - input := "> 第一段引用\n> 第二段引用\n- 列表一\n - 子项\n1. 有序项\n\n```go\nfmt.Println(\"hi\")\nfmt.Println(\"bye\")\n```\n[链接](https://example.com/path?q=1)" + input := "> first quote\n> second quote\n- item one\n - child item\n1. ordered\n\n```go\nfmt.Println(\"hi\")\nfmt.Println(\"bye\")\n```\n[link](https://example.com/path?q=1)" got := markdownToTelegramHTML(input) - if !strings.Contains(got, "> 第一段引用\n> 第二段引用") { + if !strings.Contains(got, "> first quote\n> second quote") { t.Fatalf("expected consecutive quote lines to stay stable, got %q", got) } - if !strings.Contains(got, "• 列表一") || !strings.Contains(got, "• 子项") { + if !strings.Contains(got, "• item one") || !strings.Contains(got, "• child item") { t.Fatalf("expected nested list lines to normalize to bullets, got %q", got) } if !strings.Contains(got, "
fmt.Println(\"hi\")\nfmt.Println(\"bye\")\n
") { t.Fatalf("expected code block newlines preserved, got %q", got) } - if !strings.Contains(got, `链接`) { + if !strings.Contains(got, `link`) { t.Fatalf("expected link conversion, got %q", got) } } diff --git a/pkg/channels/utils.go b/pkg/channels/utils.go index a1f38ea..b5e5d71 100644 --- a/pkg/channels/utils.go +++ b/pkg/channels/utils.go @@ -18,15 +18,6 @@ func truncateString(s string, maxLen int) string { return s[:maxLen] } -func safeCloseSignal(v interface{}) { - ch, ok := v.(chan struct{}) - if !ok || ch == nil { - return - } - defer func() { _ = recover() }() - close(ch) -} - type cancelGuard struct { mu sync.Mutex cancel context.CancelFunc diff --git a/pkg/channels/whatsapp_bridge.go b/pkg/channels/whatsapp_bridge.go index 450f832..8e3732f 100644 --- a/pkg/channels/whatsapp_bridge.go +++ b/pkg/channels/whatsapp_bridge.go @@ -769,14 +769,6 @@ func (s *WhatsAppBridgeService) updateStatus(mut func(*WhatsAppBridgeStatus)) { s.status.UpdatedAt = time.Now().Format(time.RFC3339) } -func (s *WhatsAppBridgeService) broadcastWS(payload whatsappBridgeWSMessage) { - s.wsClientsMu.Lock() - defer s.wsClientsMu.Unlock() - for conn := range s.wsClients { - _ = conn.WriteJSON(payload) - } -} - func (s *WhatsAppBridgeService) broadcastWSMap(payload map[string]interface{}) { s.wsClientsMu.Lock() defer s.wsClientsMu.Unlock() diff --git a/pkg/config/config.go b/pkg/config/config.go index 9da678c..b28b11e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -45,7 +45,6 @@ type AgentRouterConfig struct { AllowDirectAgentChat bool `json:"allow_direct_agent_chat,omitempty"` MaxHops int `json:"max_hops,omitempty"` DefaultTimeoutSec int `json:"default_timeout_sec,omitempty"` - DefaultWaitReply bool `json:"default_wait_reply,omitempty"` StickyThreadOwner bool `json:"sticky_thread_owner,omitempty"` } @@ -465,7 +464,7 @@ func DefaultConfig() *Config { Execution: AgentExecutionConfig{ RunStateTTLSeconds: 1800, RunStateMax: 500, - ToolParallelSafeNames: []string{"read_file", "list_files", "find_files", "grep_files", "memory_search", "web_search", "repo_map", "system_info"}, + ToolParallelSafeNames: []string{"read_file", "list_files", "find_files", "grep_files", "memory_search", "web_search", "system_info"}, ToolMaxParallelCalls: 2, }, SummaryPolicy: SystemSummaryPolicyConfig{ @@ -490,7 +489,6 @@ func DefaultConfig() *Config { AllowDirectAgentChat: false, MaxHops: 6, DefaultTimeoutSec: 600, - DefaultWaitReply: true, StickyThreadOwner: true, }, Communication: AgentCommunicationConfig{ diff --git a/pkg/config/normalized.go b/pkg/config/normalized.go index 61cb163..9871422 100644 --- a/pkg/config/normalized.go +++ b/pkg/config/normalized.go @@ -46,7 +46,6 @@ type NormalizedRuntimeRouterConfig struct { AllowDirectAgentChat bool `json:"allow_direct_agent_chat,omitempty"` MaxHops int `json:"max_hops,omitempty"` DefaultTimeoutSec int `json:"default_timeout_sec,omitempty"` - DefaultWaitReply bool `json:"default_wait_reply,omitempty"` StickyThreadOwner bool `json:"sticky_thread_owner,omitempty"` Rules []AgentRouteRule `json:"rules,omitempty"` } @@ -119,7 +118,6 @@ func (c *Config) NormalizedView() NormalizedConfig { AllowDirectAgentChat: c.Agents.Router.AllowDirectAgentChat, MaxHops: c.Agents.Router.MaxHops, DefaultTimeoutSec: c.Agents.Router.DefaultTimeoutSec, - DefaultWaitReply: c.Agents.Router.DefaultWaitReply, StickyThreadOwner: c.Agents.Router.StickyThreadOwner, Rules: append([]AgentRouteRule(nil), c.Agents.Router.Rules...), }, @@ -223,7 +221,6 @@ func (c *Config) ApplyNormalizedView(view NormalizedConfig) { if view.Runtime.Router.DefaultTimeoutSec > 0 { c.Agents.Router.DefaultTimeoutSec = view.Runtime.Router.DefaultTimeoutSec } - c.Agents.Router.DefaultWaitReply = view.Runtime.Router.DefaultWaitReply c.Agents.Router.StickyThreadOwner = view.Runtime.Router.StickyThreadOwner c.Agents.Router.Rules = append([]AgentRouteRule(nil), view.Runtime.Router.Rules...) diff --git a/pkg/config/validate_test.go b/pkg/config/validate_test.go index a7ef6e8..048fec8 100644 --- a/pkg/config/validate_test.go +++ b/pkg/config/validate_test.go @@ -1,6 +1,7 @@ package config import ( + "path/filepath" "strings" "testing" ) @@ -62,9 +63,10 @@ func TestValidateSubagentsRejectsAbsolutePromptFile(t *testing.T) { t.Parallel() cfg := DefaultConfig() + absolutePrompt := filepath.Join(t.TempDir(), "AGENT.md") cfg.Agents.Subagents["coder"] = SubagentConfig{ Enabled: true, - SystemPromptFile: "/tmp/AGENT.md", + SystemPromptFile: absolutePrompt, Runtime: SubagentRuntimeConfig{ Provider: "openai", }, diff --git a/pkg/ekg/engine.go b/pkg/ekg/engine.go deleted file mode 100644 index d07b934..0000000 --- a/pkg/ekg/engine.go +++ /dev/null @@ -1,337 +0,0 @@ -package ekg - -import ( - "bufio" - "encoding/json" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "time" -) - -type Event struct { - Time string `json:"time"` - TaskID string `json:"task_id,omitempty"` - Session string `json:"session,omitempty"` - Channel string `json:"channel,omitempty"` - Source string `json:"source,omitempty"` - Status string `json:"status"` // success|error|suppressed - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - ErrSig string `json:"errsig,omitempty"` - Log string `json:"log,omitempty"` -} - -type SignalContext struct { - TaskID string - ErrSig string - Source string - Channel string -} - -type Advice struct { - ShouldEscalate bool `json:"should_escalate"` - RetryBackoffSec int `json:"retry_backoff_sec"` - Reason []string `json:"reason"` -} - -type Engine struct { - workspace string - path string - snapshotPath string - recentLines int - consecutiveErrorThreshold int -} - -func New(workspace string) *Engine { - ws := strings.TrimSpace(workspace) - p := filepath.Join(ws, "memory", "ekg-events.jsonl") - sp := filepath.Join(ws, "memory", "ekg-snapshot.json") - return &Engine{workspace: ws, path: p, snapshotPath: sp, recentLines: 2000, consecutiveErrorThreshold: 3} -} - -func (e *Engine) SetConsecutiveErrorThreshold(v int) { - if e == nil { - return - } - if v <= 0 { - v = 3 - } - e.consecutiveErrorThreshold = v -} - -func (e *Engine) Record(ev Event) { - if e == nil || strings.TrimSpace(e.path) == "" { - return - } - if strings.TrimSpace(ev.Time) == "" { - ev.Time = time.Now().UTC().Format(time.RFC3339) - } - ev.TaskID = strings.TrimSpace(ev.TaskID) - ev.Session = strings.TrimSpace(ev.Session) - ev.Channel = strings.TrimSpace(ev.Channel) - ev.Source = strings.TrimSpace(ev.Source) - ev.Status = strings.TrimSpace(strings.ToLower(ev.Status)) - ev.Provider = strings.TrimSpace(ev.Provider) - ev.Model = strings.TrimSpace(ev.Model) - if ev.Status == "error" && ev.ErrSig == "" && ev.Log != "" { - ev.ErrSig = NormalizeErrorSignature(ev.Log) - } - if ev.ErrSig != "" { - ev.ErrSig = NormalizeErrorSignature(ev.ErrSig) - } - _ = os.MkdirAll(filepath.Dir(e.path), 0o755) - b, err := json.Marshal(ev) - if err != nil { - return - } - f, err := os.OpenFile(e.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - return - } - defer f.Close() - _, _ = f.Write(append(b, '\n')) -} - -func (e *Engine) GetAdvice(ctx SignalContext) Advice { - adv := Advice{ShouldEscalate: false, RetryBackoffSec: 30, Reason: []string{}} - if e == nil { - return adv - } - taskID := strings.TrimSpace(ctx.TaskID) - errSig := NormalizeErrorSignature(ctx.ErrSig) - if taskID == "" || errSig == "" { - return adv - } - events := e.readRecentEvents() - if len(events) == 0 { - return adv - } - consecutive := 0 - for i := len(events) - 1; i >= 0; i-- { - ev := events[i] - if strings.TrimSpace(ev.TaskID) != taskID { - continue - } - evErr := NormalizeErrorSignature(ev.ErrSig) - if evErr == "" { - evErr = NormalizeErrorSignature(ev.Log) - } - if evErr != errSig { - continue - } - if strings.ToLower(strings.TrimSpace(ev.Status)) == "error" { - consecutive++ - if consecutive >= e.consecutiveErrorThreshold { - adv.ShouldEscalate = true - adv.RetryBackoffSec = 300 - adv.Reason = append(adv.Reason, "repeated_error_signature") - adv.Reason = append(adv.Reason, "same task and error signature exceeded threshold") - return adv - } - // Memory-linked fast path: if this errsig was documented as incident, escalate one step earlier. - if consecutive >= e.consecutiveErrorThreshold-1 && e.hasMemoryIncident(errSig) { - adv.ShouldEscalate = true - adv.RetryBackoffSec = 300 - adv.Reason = append(adv.Reason, "memory_linked_repeated_error_signature") - adv.Reason = append(adv.Reason, "same errsig already recorded in memory incident") - return adv - } - continue - } - // Same signature but success/suppressed encountered: reset chain. - break - } - return adv -} - -func (e *Engine) readRecentEvents() []Event { - if strings.TrimSpace(e.path) == "" { - return nil - } - snapshotEvents, snapshotLines := e.readSnapshot() - f, err := os.Open(e.path) - if err != nil { - return snapshotEvents - } - defer f.Close() - lines := make([]string, 0, e.recentLines) - s := bufio.NewScanner(f) - lineNo := 0 - for s.Scan() { - lineNo++ - if lineNo <= snapshotLines { - continue - } - line := strings.TrimSpace(s.Text()) - if line == "" { - continue - } - lines = append(lines, line) - if len(lines) > e.recentLines { - lines = lines[1:] - } - } - out := append([]Event{}, snapshotEvents...) - for _, l := range lines { - var ev Event - if json.Unmarshal([]byte(l), &ev) == nil { - out = append(out, ev) - } - } - if len(out) > e.recentLines { - out = out[len(out)-e.recentLines:] - } - e.writeSnapshot(out, lineNo) - return out -} - -type snapshotFile struct { - UpdatedAt string `json:"updated_at"` - LineCount int `json:"line_count"` - Events []Event `json:"events"` -} - -func (e *Engine) readSnapshot() ([]Event, int) { - if e == nil || strings.TrimSpace(e.snapshotPath) == "" { - return nil, 0 - } - b, err := os.ReadFile(e.snapshotPath) - if err != nil || len(b) == 0 { - return nil, 0 - } - var snap snapshotFile - if json.Unmarshal(b, &snap) != nil { - return nil, 0 - } - if snap.LineCount < 0 { - snap.LineCount = 0 - } - if len(snap.Events) > e.recentLines { - snap.Events = snap.Events[len(snap.Events)-e.recentLines:] - } - return snap.Events, snap.LineCount -} - -func (e *Engine) writeSnapshot(events []Event, lineCount int) { - if e == nil || strings.TrimSpace(e.snapshotPath) == "" || lineCount <= 0 { - return - } - if len(events) > e.recentLines { - events = events[len(events)-e.recentLines:] - } - snap := snapshotFile{UpdatedAt: time.Now().UTC().Format(time.RFC3339), LineCount: lineCount, Events: events} - b, err := json.MarshalIndent(snap, "", " ") - if err != nil { - return - } - _ = os.MkdirAll(filepath.Dir(e.snapshotPath), 0o755) - tmp := e.snapshotPath + ".tmp" - if os.WriteFile(tmp, append(b, '\n'), 0o644) == nil { - _ = os.Rename(tmp, e.snapshotPath) - } -} - -var ( - rePathNum = regexp.MustCompile(`\b\d+\b`) - rePathHex = regexp.MustCompile(`\b0x[0-9a-fA-F]+\b`) - rePathWin = regexp.MustCompile(`[a-zA-Z]:\\[^\s]+`) - rePathNix = regexp.MustCompile(`/[^\s]+`) - reSpace = regexp.MustCompile(`\s+`) -) - -func (e *Engine) RankProviders(candidates []string) []string { - return e.RankProvidersForError(candidates, "") -} - -func (e *Engine) RankProvidersForError(candidates []string, errSig string) []string { - if len(candidates) <= 1 || e == nil { - return append([]string(nil), candidates...) - } - errSig = NormalizeErrorSignature(errSig) - events := e.readRecentEvents() - score := map[string]float64{} - for _, c := range candidates { - score[c] = 0 - } - for _, ev := range events { - p := strings.TrimSpace(ev.Provider) - if p == "" { - continue - } - if _, ok := score[p]; !ok { - continue - } - weight := 1.0 - evSig := NormalizeErrorSignature(ev.ErrSig) - if errSig != "" { - if evSig == errSig { - weight = 2.5 - } else if evSig != "" { - weight = 0.4 - } - } - switch strings.ToLower(strings.TrimSpace(ev.Status)) { - case "success": - score[p] += 1.0 * weight - case "suppressed": - score[p] += 0.2 * weight - case "error": - score[p] -= 1.0 * weight - } - } - ordered := append([]string(nil), candidates...) - sort.SliceStable(ordered, func(i, j int) bool { - si, sj := score[ordered[i]], score[ordered[j]] - if si == sj { - return ordered[i] < ordered[j] - } - return si > sj - }) - return ordered -} - -func (e *Engine) hasMemoryIncident(errSig string) bool { - if e == nil || strings.TrimSpace(e.workspace) == "" { - return false - } - errSig = NormalizeErrorSignature(errSig) - if errSig == "" { - return false - } - needle := "[EKG_INCIDENT]" - candidates := []string{ - filepath.Join(e.workspace, "MEMORY.md"), - filepath.Join(e.workspace, "memory", time.Now().UTC().Format("2006-01-02")+".md"), - filepath.Join(e.workspace, "memory", time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")+".md"), - } - for _, p := range candidates { - b, err := os.ReadFile(p) - if err != nil || len(b) == 0 { - continue - } - txt := strings.ToLower(string(b)) - if strings.Contains(txt, strings.ToLower(needle)) && strings.Contains(txt, errSig) { - return true - } - } - return false -} - -func NormalizeErrorSignature(s string) string { - s = strings.TrimSpace(strings.ToLower(s)) - if s == "" { - return "" - } - s = rePathWin.ReplaceAllString(s, "") - s = rePathNix.ReplaceAllString(s, "") - s = rePathHex.ReplaceAllString(s, "") - s = rePathNum.ReplaceAllString(s, "") - s = reSpace.ReplaceAllString(s, " ") - if len(s) > 240 { - s = s[:240] - } - return s -} diff --git a/pkg/nodes/transport_test.go b/pkg/nodes/transport_test.go index 8566d02..b408370 100644 --- a/pkg/nodes/transport_test.go +++ b/pkg/nodes/transport_test.go @@ -3,6 +3,7 @@ package nodes import ( "context" "encoding/json" + "path/filepath" "sync" "testing" "time" @@ -77,17 +78,18 @@ func TestWebsocketP2PTransportSend(t *testing.T) { func TestNormalizeDevicePayloadBuildsArtifacts(t *testing.T) { t.Parallel() + path := filepath.Join(string(filepath.Separator), "tmp", "screen.png") payload := normalizeDevicePayload("screen_snapshot", map[string]interface{}{ "media_type": "image", "storage": "path", - "path": "/tmp/screen.png", + "path": path, "mime_type": "image/png", }) artifacts, ok := payload["artifacts"].([]map[string]interface{}) if !ok || len(artifacts) != 1 { t.Fatalf("expected one artifact, got %+v", payload["artifacts"]) } - if artifacts[0]["kind"] != "image" || artifacts[0]["path"] != "/tmp/screen.png" { + if artifacts[0]["kind"] != "image" || filepath.Clean(artifacts[0]["path"].(string)) != filepath.Clean(path) { t.Fatalf("unexpected artifact payload: %+v", artifacts[0]) } } @@ -95,10 +97,11 @@ func TestNormalizeDevicePayloadBuildsArtifacts(t *testing.T) { func TestNormalizeDevicePayloadNormalizesExistingArtifactRows(t *testing.T) { t.Parallel() + path := filepath.Join(string(filepath.Separator), "tmp", "screen.png") payload := normalizeDevicePayload("screen_snapshot", map[string]interface{}{ "artifacts": []map[string]interface{}{ { - "path": "/tmp/screen.png", + "path": path, "kind": "image", "size_bytes": "42", }, diff --git a/pkg/providers/aistudio_provider_test.go b/pkg/providers/aistudio_provider_test.go index 98047f3..11a84ad 100644 --- a/pkg/providers/aistudio_provider_test.go +++ b/pkg/providers/aistudio_provider_test.go @@ -234,7 +234,7 @@ func TestAIStudioChannelIDPrefersHealthyAvailableRelay(t *testing.T) { } providerRuntimeRegistry.mu.Unlock() - got := aistudioChannelID("aistudio", nil) + got := aistudioChannelCandidates("aistudio", nil)[0] if got != "aistudio-good" { t.Fatalf("expected aistudio-good, got %q", got) } @@ -249,7 +249,7 @@ func TestAIStudioChannelIDExplicitOptionWins(t *testing.T) { } providerRuntimeRegistry.mu.Unlock() - got := aistudioChannelID("aistudio", map[string]interface{}{"aistudio_channel": "manual"}) + got := aistudioChannelCandidates("aistudio", map[string]interface{}{"aistudio_channel": "manual"})[0] if got != "manual" { t.Fatalf("expected explicit channel manual, got %q", got) } @@ -273,7 +273,7 @@ func TestAIStudioChannelIDPrefersMostRecentSuccessfulRelay(t *testing.T) { aistudioRelayRegistry.succeeded["aistudio-b"] = time.Now() aistudioRelayRegistry.mu.Unlock() - got := aistudioChannelID("aistudio", nil) + got := aistudioChannelCandidates("aistudio", nil)[0] if got != "aistudio-b" { t.Fatalf("expected most recent successful relay aistudio-b, got %q", got) } diff --git a/pkg/providers/aistudio_relay.go b/pkg/providers/aistudio_relay.go index 25e7122..c5526ad 100644 --- a/pkg/providers/aistudio_relay.go +++ b/pkg/providers/aistudio_relay.go @@ -35,14 +35,6 @@ func getAIStudioRelayManager() *wsrelay.Manager { return aistudioRelayRegistry.manager } -func aistudioChannelID(providerName string, options map[string]interface{}) string { - channels := aistudioChannelCandidates(providerName, options) - if len(channels) > 0 { - return channels[0] - } - return "" -} - func aistudioChannelCandidates(providerName string, options map[string]interface{}) []string { for _, key := range []string{"aistudio_channel", "aistudio_provider", "relay_provider", "channel_id", "provider_id"} { if value, ok := stringOption(options, key); ok && strings.TrimSpace(value) != "" { diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index f83e848..80b44be 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -311,10 +311,6 @@ func (p *HTTPProvider) callResponses(ctx context.Context, messages []Message, to return p.postJSON(ctx, endpointFor(p.apiBase, "/responses"), requestBody) } -func toResponsesInputItems(msg Message) []map[string]interface{} { - return toResponsesInputItemsWithState(msg, nil) -} - func toResponsesInputItemsWithState(msg Message, pendingCalls map[string]struct{}) []map[string]interface{} { role := strings.ToLower(strings.TrimSpace(msg.Role)) switch role { @@ -2231,13 +2227,6 @@ func (p *HTTPProvider) codexCompatRequestBody(requestBody map[string]interface{} return codexCompatRequestBody(requestBody) } -func (p *HTTPProvider) useClaudeCompat() bool { - if p == nil || p.oauth == nil { - return false - } - return strings.EqualFold(strings.TrimSpace(p.oauth.cfg.Provider), defaultClaudeOAuthProvider) -} - func (p *HTTPProvider) oauthProvider() string { if p == nil || p.oauth == nil { return "" @@ -2723,40 +2712,6 @@ func CreateProviderByName(cfg *config.Config, name string) (LLMProvider, error) return NewHTTPProvider(routeName, pc.APIKey, pc.APIBase, defaultModel, pc.SupportsResponsesCompact, pc.Auth, time.Duration(pc.TimeoutSec)*time.Second, oauth), nil } -func CreateProviders(cfg *config.Config) (map[string]LLMProvider, error) { - configs := getAllProviderConfigs(cfg) - if len(configs) == 0 { - return nil, fmt.Errorf("no providers configured") - } - out := make(map[string]LLMProvider, len(configs)) - for name := range configs { - p, err := CreateProviderByName(cfg, name) - if err != nil { - return nil, err - } - out[name] = p - } - return out, nil -} - -func GetProviderModels(cfg *config.Config, name string) []string { - pc, err := getProviderConfigByName(cfg, name) - if err != nil { - return nil - } - out := make([]string, 0, len(pc.Models)) - seen := map[string]bool{} - for _, m := range pc.Models { - model := strings.TrimSpace(m) - if model == "" || seen[model] { - continue - } - seen[model] = true - out = append(out, model) - } - return out -} - func ProviderSupportsResponsesCompact(cfg *config.Config, name string) bool { pc, err := getProviderConfigByName(cfg, name) if err != nil { @@ -2765,18 +2720,6 @@ func ProviderSupportsResponsesCompact(cfg *config.Config, name string) bool { return pc.SupportsResponsesCompact } -func ListProviderNames(cfg *config.Config) []string { - configs := getAllProviderConfigs(cfg) - if len(configs) == 0 { - return nil - } - names := make([]string, 0, len(configs)) - for name := range configs { - names = append(names, name) - } - return names -} - func getAllProviderConfigs(cfg *config.Config) map[string]config.ProviderConfig { return config.AllProviderConfigs(cfg) } diff --git a/pkg/providers/oauth.go b/pkg/providers/oauth.go index 6368939..76d860a 100644 --- a/pkg/providers/oauth.go +++ b/pkg/providers/oauth.go @@ -318,10 +318,6 @@ func (m *OAuthLoginManager) CredentialFile() string { return m.manager.cfg.CredentialFile } -func (m *OAuthLoginManager) StartManualFlow() (*OAuthPendingFlow, error) { - return m.StartManualFlowWithOptions(OAuthLoginOptions{}) -} - func (m *OAuthLoginManager) StartManualFlowWithOptions(opts OAuthLoginOptions) (*OAuthPendingFlow, error) { if m == nil || m.manager == nil { return nil, fmt.Errorf("oauth login manager not configured") @@ -351,10 +347,6 @@ func (m *OAuthLoginManager) StartManualFlowWithOptions(opts OAuthLoginOptions) ( }, nil } -func (m *OAuthLoginManager) CompleteManualFlow(ctx context.Context, apiBase string, flow *OAuthPendingFlow, callbackURL string) (*OAuthSessionInfo, []string, error) { - return m.CompleteManualFlowWithOptions(ctx, apiBase, flow, callbackURL, OAuthLoginOptions{}) -} - func (m *OAuthLoginManager) CompleteManualFlowWithOptions(ctx context.Context, apiBase string, flow *OAuthPendingFlow, callbackURL string, opts OAuthLoginOptions) (*OAuthSessionInfo, []string, error) { if m == nil || m.manager == nil { return nil, nil, fmt.Errorf("oauth login manager not configured") @@ -392,10 +384,6 @@ func (m *OAuthLoginManager) CompleteManualFlowWithOptions(ctx context.Context, a }, models, nil } -func (m *OAuthLoginManager) ImportAuthJSON(ctx context.Context, apiBase string, fileName string, data []byte) (*OAuthSessionInfo, []string, error) { - return m.ImportAuthJSONWithOptions(ctx, apiBase, fileName, data, OAuthLoginOptions{}) -} - func (m *OAuthLoginManager) ImportAuthJSONWithOptions(ctx context.Context, apiBase string, fileName string, data []byte, opts OAuthLoginOptions) (*OAuthSessionInfo, []string, error) { if m == nil || m.manager == nil { return nil, nil, fmt.Errorf("oauth login manager not configured") @@ -746,47 +734,6 @@ func defaultRefreshLead(provider string, overrideSec int) time.Duration { } } -func (m *oauthManager) models(ctx context.Context, apiBase string) ([]string, error) { - attempts, err := m.prepareAttemptsLocked(ctx) - if err != nil { - return nil, err - } - if len(attempts) == 0 { - return nil, fmt.Errorf("oauth session not found, run `clawgo provider login` first") - } - var merged []string - seen := map[string]struct{}{} - var lastErr error - for _, attempt := range attempts { - client, err := m.httpClientForSession(attempt.Session) - if err != nil { - lastErr = err - continue - } - models, err := fetchOpenAIModels(ctx, client, apiBase, attempt.Token) - if err != nil { - lastErr = err - continue - } - attempt.Session.Models = append([]string(nil), models...) - _ = m.persistSessionLocked(attempt.Session) - for _, model := range models { - if _, ok := seen[model]; ok { - continue - } - seen[model] = struct{}{} - merged = append(merged, model) - } - } - if len(merged) > 0 { - return merged, nil - } - if lastErr != nil { - return nil, lastErr - } - return nil, fmt.Errorf("no oauth sessions available") -} - func (m *oauthManager) login(ctx context.Context, apiBase string, opts OAuthLoginOptions) (*oauthSession, []string, error) { if m == nil { return nil, nil, fmt.Errorf("oauth manager not configured") diff --git a/pkg/providers/openai_compat_provider.go b/pkg/providers/openai_compat_provider.go index c349972..d409c4d 100644 --- a/pkg/providers/openai_compat_provider.go +++ b/pkg/providers/openai_compat_provider.go @@ -17,85 +17,6 @@ func openAICompatDefaultModel(base *HTTPProvider) string { return base.GetDefaultModel() } -func runQwenChat(ctx context.Context, base *HTTPProvider, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - if base == nil { - return nil, fmt.Errorf("provider not configured") - } - requestBody := buildQwenChatRequest(base, messages, tools, model, options, false) - body, statusCode, contentType, err := doOpenAICompatJSONWithAttempts(ctx, base, "/chat/completions", requestBody, qwenProviderHooks{}) - if err != nil { - return nil, err - } - if statusCode != http.StatusOK { - return nil, fmt.Errorf("API error (status %d, content-type %q): %s", statusCode, contentType, previewResponseBody(body)) - } - if !json.Valid(body) { - return nil, fmt.Errorf("API error (status %d, content-type %q): non-JSON response: %s", statusCode, contentType, previewResponseBody(body)) - } - return parseOpenAICompatResponse(body) -} - -func runQwenChatStream(ctx context.Context, base *HTTPProvider, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, onDelta func(string)) (*LLMResponse, error) { - if base == nil { - return nil, fmt.Errorf("provider not configured") - } - if onDelta == nil { - onDelta = func(string) {} - } - requestBody := buildQwenChatRequest(base, messages, tools, model, options, true) - body, statusCode, contentType, err := doOpenAICompatStreamWithAttempts(ctx, base, "/chat/completions", requestBody, onDelta, qwenProviderHooks{}) - if err != nil { - return nil, err - } - if statusCode != http.StatusOK { - return nil, fmt.Errorf("API error (status %d, content-type %q): %s", statusCode, contentType, previewResponseBody(body)) - } - if !json.Valid(body) { - return nil, fmt.Errorf("API error (status %d, content-type %q): non-JSON response: %s", statusCode, contentType, previewResponseBody(body)) - } - return parseOpenAICompatResponse(body) -} - -func runOpenAICompatChat(ctx context.Context, base *HTTPProvider, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - if base == nil { - return nil, fmt.Errorf("provider not configured") - } - body, statusCode, contentType, err := doOpenAICompatJSONWithAttempts(ctx, base, "/chat/completions", base.buildOpenAICompatChatRequest(messages, tools, model, options), nil) - if err != nil { - return nil, err - } - if statusCode != http.StatusOK { - return nil, fmt.Errorf("API error (status %d, content-type %q): %s", statusCode, contentType, previewResponseBody(body)) - } - if !json.Valid(body) { - return nil, fmt.Errorf("API error (status %d, content-type %q): non-JSON response: %s", statusCode, contentType, previewResponseBody(body)) - } - return parseOpenAICompatResponse(body) -} - -func runOpenAICompatChatStream(ctx context.Context, base *HTTPProvider, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, onDelta func(string)) (*LLMResponse, error) { - if base == nil { - return nil, fmt.Errorf("provider not configured") - } - if onDelta == nil { - onDelta = func(string) {} - } - chatBody := base.buildOpenAICompatChatRequest(messages, tools, model, options) - chatBody["stream"] = true - chatBody["stream_options"] = map[string]interface{}{"include_usage": true} - body, statusCode, contentType, err := doOpenAICompatStreamWithAttempts(ctx, base, "/chat/completions", chatBody, onDelta, nil) - if err != nil { - return nil, err - } - if statusCode != http.StatusOK { - return nil, fmt.Errorf("API error (status %d, content-type %q): %s", statusCode, contentType, previewResponseBody(body)) - } - if !json.Valid(body) { - return nil, fmt.Errorf("API error (status %d, content-type %q): non-JSON response: %s", statusCode, contentType, previewResponseBody(body)) - } - return parseOpenAICompatResponse(body) -} - type openAICompatHooks interface { beforeAttempt(attempt authAttempt) (int, []byte, string, bool) endpoint(base *HTTPProvider, attempt authAttempt, path string) string diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 56a9cec..37fadae 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -59,7 +59,6 @@ func NewSessionManager(storage string) *SessionManager { if storage != "" { os.MkdirAll(storage, 0755) - sm.migrateLegacySessions() sm.cleanupArchivedSessions() sm.loadSessions() } @@ -172,20 +171,6 @@ func (sm *SessionManager) GetSummary(key string) string { return session.Summary } -func (sm *SessionManager) SetSummary(key string, summary string) { - sm.mu.RLock() - session, ok := sm.sessions[key] - sm.mu.RUnlock() - - if ok { - session.mu.Lock() - defer session.mu.Unlock() - - session.Summary = summary - session.Updated = time.Now() - } -} - func (sm *SessionManager) CompactSession(key string, keepLast int, note string) bool { sm.mu.RLock() session, ok := sm.sessions[key] @@ -247,119 +232,6 @@ func (sm *SessionManager) SetPreferredLanguage(key, lang string) { session.mu.Unlock() } -func (sm *SessionManager) PurgeOrphanToolOutputs(key string) int { - sm.mu.RLock() - session, ok := sm.sessions[key] - sm.mu.RUnlock() - if !ok { - return 0 - } - - session.mu.Lock() - defer session.mu.Unlock() - pending := map[string]struct{}{} - kept := make([]providers.Message, 0, len(session.Messages)) - removed := 0 - for _, m := range session.Messages { - role := strings.ToLower(strings.TrimSpace(m.Role)) - switch role { - case "assistant": - for _, tc := range m.ToolCalls { - id := strings.TrimSpace(tc.ID) - if id != "" { - pending[id] = struct{}{} - } - } - kept = append(kept, m) - case "tool": - id := strings.TrimSpace(m.ToolCallID) - if id == "" { - removed++ - continue - } - if _, ok := pending[id]; !ok { - removed++ - continue - } - delete(pending, id) - kept = append(kept, m) - default: - kept = append(kept, m) - } - } - if removed == 0 { - return 0 - } - session.Messages = kept - session.Updated = time.Now() - if sm.storage != "" { - _ = sm.rewriteSessionFileLocked(session) - _ = sm.writeOpenClawSessionsIndex() - } - return removed -} - -func (sm *SessionManager) rewriteSessionFileLocked(session *Session) error { - if sm.storage == "" || session == nil { - return nil - } - path := filepath.Join(sm.storage, session.Key+".jsonl") - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - for _, msg := range session.Messages { - e := toOpenClawMessageEvent(msg) - b, err := json.Marshal(e) - if err != nil { - return fmt.Errorf("marshal session message: %w", err) - } - if _, err := f.Write(append(b, '\n')); err != nil { - return err - } - } - return nil -} - -func (sm *SessionManager) ResetSession(key string) { - sm.mu.RLock() - session, ok := sm.sessions[key] - sm.mu.RUnlock() - if !ok { - return - } - session.mu.Lock() - session.Messages = []providers.Message{} - session.Summary = "" - session.Updated = time.Now() - if sm.storage != "" { - _ = sm.rewriteSessionFileLocked(session) - _ = sm.writeOpenClawSessionsIndex() - } - session.mu.Unlock() -} - -func (sm *SessionManager) TruncateHistory(key string, keepLast int) { - sm.mu.RLock() - session, ok := sm.sessions[key] - sm.mu.RUnlock() - - if !ok { - return - } - - session.mu.Lock() - defer session.mu.Unlock() - - if len(session.Messages) <= keepLast { - return - } - - session.Messages = session.Messages[len(session.Messages)-keepLast:] - session.Updated = time.Now() -} - func (sm *SessionManager) Save(session *Session) error { // Messages are persisted incrementally via AddMessageFull. // Metadata is now centralized in sessions.json (OpenClaw-style index). @@ -531,52 +403,9 @@ func (sm *SessionManager) writeOpenClawSessionsIndex() error { if err := os.WriteFile(filepath.Join(sm.storage, "sessions.json"), data, 0644); err != nil { return err } - // Cleanup legacy .meta files: sessions.json is source of truth now. - entries, _ := os.ReadDir(sm.storage) - for _, e := range entries { - if e.IsDir() || filepath.Ext(e.Name()) != ".meta" { - continue - } - _ = os.Remove(filepath.Join(sm.storage, e.Name())) - } return nil } -func (sm *SessionManager) migrateLegacySessions() { - if sm.storage == "" { - return - } - root := filepath.Dir(filepath.Dir(filepath.Dir(sm.storage))) // ~/.clawgo - candidates := []string{ - filepath.Join(root, "sessions"), - filepath.Join(filepath.Dir(sm.storage), "sessions"), - } - for _, legacy := range candidates { - if strings.TrimSpace(legacy) == "" || legacy == sm.storage { - continue - } - entries, err := os.ReadDir(legacy) - if err != nil { - continue - } - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - if !(strings.HasSuffix(name, ".jsonl") || name == "sessions.json" || strings.Contains(name, ".jsonl.deleted.")) { - continue - } - src := filepath.Join(legacy, name) - dst := filepath.Join(sm.storage, name) - if _, err := os.Stat(dst); err == nil { - continue - } - _ = os.Rename(src, dst) - } - } -} - func (sm *SessionManager) cleanupArchivedSessions() { if sm.storage == "" { return diff --git a/pkg/session/manager_test.go b/pkg/session/manager_test.go index 3530c51..a28af3d 100644 --- a/pkg/session/manager_test.go +++ b/pkg/session/manager_test.go @@ -5,8 +5,6 @@ import ( "path/filepath" "strings" "testing" - - "github.com/YspCoder/clawgo/pkg/providers" ) func TestLoadSessionsReturnsScannerErrorForOversizedLine(t *testing.T) { @@ -40,25 +38,3 @@ func TestFromJSONLLineParsesOpenClawToolResult(t *testing.T) { } } -func TestRewriteSessionFileLockedPersistsMessages(t *testing.T) { - t.Parallel() - - storage := t.TempDir() - sm := &SessionManager{storage: storage} - session := &Session{ - Key: "abc", - Messages: []providers.Message{ - {Role: "user", Content: "hello"}, - }, - } - if err := sm.rewriteSessionFileLocked(session); err != nil { - t.Fatalf("rewrite session failed: %v", err) - } - data, err := os.ReadFile(filepath.Join(storage, "abc.jsonl")) - if err != nil { - t.Fatalf("read rewritten session failed: %v", err) - } - if !strings.Contains(string(data), `"role":"user"`) { - t.Fatalf("unexpected rewritten session contents: %s", string(data)) - } -} diff --git a/pkg/tools/command_tick_limits_test.go b/pkg/tools/command_tick_limits_test.go deleted file mode 100644 index 9bdb02a..0000000 --- a/pkg/tools/command_tick_limits_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package tools - -import "testing" - -func TestComputeDynamicActiveSlots_ReservesTwentyPercent(t *testing.T) { - got := computeDynamicActiveSlots(10, 0.20, 0.0, 12) - if got != 8 { - t.Fatalf("expected 8 active slots with 20%% reserve on 10 CPU, got %d", got) - } -} - -func TestComputeDynamicActiveSlots_ReducesWithSystemUsage(t *testing.T) { - got := computeDynamicActiveSlots(10, 0.20, 0.5, 12) - if got != 3 { - t.Fatalf("expected 3 active slots when system usage is 50%%, got %d", got) - } -} - -func TestComputeDynamicActiveSlots_AlwaysKeepsOne(t *testing.T) { - got := computeDynamicActiveSlots(8, 0.20, 0.95, 12) - if got != 1 { - t.Fatalf("expected at least 1 active slot under high system usage, got %d", got) - } -} diff --git a/pkg/tools/compat_alias.go b/pkg/tools/compat_alias.go deleted file mode 100644 index 8330910..0000000 --- a/pkg/tools/compat_alias.go +++ /dev/null @@ -1,46 +0,0 @@ -package tools - -import ( - "context" -) - -// AliasTool exposes OpenClaw-compatible tool names while forwarding to existing implementations. -type AliasTool struct { - name string - description string - base Tool - argMap map[string]string -} - -func NewAliasTool(name, description string, base Tool, argMap map[string]string) *AliasTool { - return &AliasTool{name: name, description: description, base: base, argMap: argMap} -} - -func (t *AliasTool) Name() string { return t.name } -func (t *AliasTool) Description() string { - if t.description != "" { - return t.description - } - return t.base.Description() -} -func (t *AliasTool) Parameters() map[string]interface{} { return t.base.Parameters() } - -func (t *AliasTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { - if args == nil { - args = map[string]interface{}{} - } - normalized := make(map[string]interface{}, len(args)+len(t.argMap)) - for key, value := range args { - normalized[key] = value - } - if len(t.argMap) > 0 { - for from, to := range t.argMap { - if v, ok := normalized[from]; ok { - if _, exists := normalized[to]; !exists { - normalized[to] = v - } - } - } - } - return t.base.Execute(ctx, normalized) -} diff --git a/pkg/tools/compat_alias_test.go b/pkg/tools/compat_alias_test.go deleted file mode 100644 index a39d277..0000000 --- a/pkg/tools/compat_alias_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package tools - -import ( - "context" - "testing" -) - -type captureAliasTool struct { - args map[string]interface{} -} - -func (t *captureAliasTool) Name() string { return "capture" } -func (t *captureAliasTool) Description() string { return "capture" } -func (t *captureAliasTool) Parameters() map[string]interface{} { - return map[string]interface{}{} -} -func (t *captureAliasTool) Execute(_ context.Context, args map[string]interface{}) (string, error) { - t.args = args - return "ok", nil -} - -func TestAliasToolExecuteDoesNotMutateCallerArgs(t *testing.T) { - t.Parallel() - - base := &captureAliasTool{} - tool := NewAliasTool("read", "", base, map[string]string{"file_path": "path"}) - original := map[string]interface{}{"file_path": "README.md"} - if _, err := tool.Execute(context.Background(), original); err != nil { - t.Fatalf("execute failed: %v", err) - } - if _, ok := original["path"]; ok { - t.Fatalf("caller args were mutated: %+v", original) - } - if got, _ := base.args["path"].(string); got != "README.md" { - t.Fatalf("expected translated arg, got %+v", base.args) - } -} diff --git a/pkg/tools/highlevel_arg_parsing_test.go b/pkg/tools/highlevel_arg_parsing_test.go index 64efbe5..9bc9a32 100644 --- a/pkg/tools/highlevel_arg_parsing_test.go +++ b/pkg/tools/highlevel_arg_parsing_test.go @@ -34,30 +34,6 @@ func TestSessionsToolParsesStringArguments(t *testing.T) { } } -func TestSubagentsToolParsesStringLimits(t *testing.T) { - manager := NewSubagentManager(nil, t.TempDir(), nil) - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "check", - Label: "demo", - AgentID: "coder", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - tool := NewSubagentsTool(manager) - out, err := tool.Execute(context.Background(), map[string]interface{}{ - "action": "list", - "limit": "1", - }) - if err != nil { - t.Fatalf("subagents execute failed: %v", err) - } - if !strings.Contains(out, "Subagents:") { - t.Fatalf("unexpected output: %s", out) - } - time.Sleep(50 * time.Millisecond) -} - func TestNodesToolParsesStringDurationAndArtifactPaths(t *testing.T) { t.Parallel() diff --git a/pkg/tools/io_arg_parsing_test.go b/pkg/tools/io_arg_parsing_test.go index 9f46e81..8e884d2 100644 --- a/pkg/tools/io_arg_parsing_test.go +++ b/pkg/tools/io_arg_parsing_test.go @@ -2,6 +2,8 @@ package tools import ( "context" + "os/exec" + "runtime" "strings" "testing" "time" @@ -70,7 +72,7 @@ func TestFilesystemToolsParseStringArgs(t *testing.T) { func TestSpawnToolParsesStringNumbers(t *testing.T) { manager := NewSubagentManager(nil, t.TempDir(), nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + manager.SetRunFunc(func(ctx context.Context, run *SubagentRun) (string, error) { return "ok", nil }) tool := NewSpawnTool(manager) @@ -95,8 +97,14 @@ func TestExecBrowserWebToolsParseStringArgs(t *testing.T) { t.Parallel() execTool := NewExecTool(configShellForTest(), t.TempDir(), NewProcessManager(t.TempDir())) + command := "printf hi" + if runtime.GOOS == "windows" { + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh is not available in test environment") + } + } execOut, err := execTool.Execute(context.Background(), map[string]interface{}{ - "command": "printf hi", + "command": command, "background": "false", }) if err != nil { diff --git a/pkg/tools/mcp_test.go b/pkg/tools/mcp_test.go index 577279a..050bb1e 100644 --- a/pkg/tools/mcp_test.go +++ b/pkg/tools/mcp_test.go @@ -579,18 +579,23 @@ func TestResolveMCPWorkingDirWorkspaceScoped(t *testing.T) { func TestResolveMCPWorkingDirRejectsOutsideWorkspaceWithoutFullPermission(t *testing.T) { workspace := t.TempDir() - _, err := resolveMCPWorkingDir(workspace, config.MCPServerConfig{WorkingDir: "/"}) + outside := filepath.Dir(workspace) + if filepath.Clean(outside) == filepath.Clean(workspace) { + t.Skip("unable to construct outside-workspace path") + } + _, err := resolveMCPWorkingDir(workspace, config.MCPServerConfig{WorkingDir: outside}) if err == nil { t.Fatal("expected outside-workspace path to be rejected") } } func TestResolveMCPWorkingDirAllowsAbsolutePathWithFullPermission(t *testing.T) { - dir, err := resolveMCPWorkingDir(t.TempDir(), config.MCPServerConfig{Permission: "full", WorkingDir: "/"}) + absolute := filepath.Clean(filepath.Dir(t.TempDir())) + dir, err := resolveMCPWorkingDir(t.TempDir(), config.MCPServerConfig{Permission: "full", WorkingDir: absolute}) if err != nil { t.Fatalf("resolveMCPWorkingDir returned error: %v", err) } - if dir != "/" { + if dir != absolute { t.Fatalf("unexpected working dir: %q", dir) } } diff --git a/pkg/tools/memory.go b/pkg/tools/memory.go index 0084ef9..fae8644 100644 --- a/pkg/tools/memory.go +++ b/pkg/tools/memory.go @@ -171,12 +171,6 @@ func (t *MemorySearchTool) getMemoryFiles(namespace string) []string { files = append(files, mainMem) } - // Backward-compatible location: memory/MEMORY.md - legacyMem := filepath.Join(base, "memory", "MEMORY.md") - if _, err := os.Stat(legacyMem); err == nil { - files = append(files, legacyMem) - } - // Recursively include memory/**/*.md memDir := filepath.Join(base, "memory") _ = filepath.WalkDir(memDir, func(path string, d os.DirEntry, err error) error { diff --git a/pkg/tools/memory_index.go b/pkg/tools/memory_index.go deleted file mode 100644 index c44a995..0000000 --- a/pkg/tools/memory_index.go +++ /dev/null @@ -1,197 +0,0 @@ -package tools - -import ( - "crypto/sha1" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" -) - -type memoryIndex struct { - UpdatedAt int64 `json:"updated_at"` - Files map[string]int64 `json:"files"` - Entries []memoryIndexEntry `json:"entries"` - Inverted map[string][]int `json:"inverted"` - Meta map[string]map[string][]string `json:"meta"` -} - -type memoryIndexEntry struct { - ID string `json:"id"` - File string `json:"file"` - LineNum int `json:"line_num"` - Content string `json:"content"` -} - -func (t *MemorySearchTool) indexPath() string { - return filepath.Join(t.workspace, "memory", ".index.json") -} - -func (t *MemorySearchTool) loadOrBuildIndex(files []string) (*memoryIndex, error) { - path := t.indexPath() - - if idx, ok := t.loadIndex(path); ok && !t.shouldRebuildIndex(idx, files) { - return idx, nil - } - return t.buildAndSaveIndex(path, files) -} - -func (t *MemorySearchTool) loadIndex(path string) (*memoryIndex, bool) { - data, err := os.ReadFile(path) - if err != nil { - return nil, false - } - var idx memoryIndex - if err := json.Unmarshal(data, &idx); err != nil { - return nil, false - } - if idx.Files == nil || idx.Inverted == nil { - return nil, false - } - return &idx, true -} - -func (t *MemorySearchTool) shouldRebuildIndex(idx *memoryIndex, files []string) bool { - if idx == nil { - return true - } - if len(idx.Files) != len(files) { - return true - } - for _, file := range files { - st, err := os.Stat(file) - if err != nil { - return true - } - if prev, ok := idx.Files[file]; !ok || prev != st.ModTime().UnixMilli() { - return true - } - } - return false -} - -func (t *MemorySearchTool) buildAndSaveIndex(path string, files []string) (*memoryIndex, error) { - idx := &memoryIndex{ - UpdatedAt: time.Now().UnixMilli(), - Files: make(map[string]int64, len(files)), - Entries: []memoryIndexEntry{}, - Inverted: map[string][]int{}, - Meta: map[string]map[string][]string{"sections": {}}, - } - - for _, file := range files { - st, err := os.Stat(file) - if err != nil { - continue - } - idx.Files[file] = st.ModTime().UnixMilli() - - blocks, sections, err := t.extractBlocks(file) - if err != nil { - continue - } - idx.Meta["sections"][file] = sections - - for _, block := range blocks { - entry := memoryIndexEntry{ - ID: hashEntryID(file, block.lineNum, block.content), - File: file, - LineNum: block.lineNum, - Content: block.content, - } - entryPos := len(idx.Entries) - idx.Entries = append(idx.Entries, entry) - - tokens := tokenize(block.content) - seen := map[string]struct{}{} - for _, token := range tokens { - if _, ok := seen[token]; ok { - continue - } - seen[token] = struct{}{} - idx.Inverted[token] = append(idx.Inverted[token], entryPos) - } - } - } - - for token, ids := range idx.Inverted { - sort.Ints(ids) - idx.Inverted[token] = uniqueInt(ids) - } - - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return idx, nil - } - if data, err := json.Marshal(idx); err == nil { - _ = os.WriteFile(path, data, 0644) - } - return idx, nil -} - -func (t *MemorySearchTool) extractBlocks(path string) ([]searchResult, []string, error) { - results, err := t.searchFile(path, []string{}) - if err != nil { - return nil, nil, err - } - sections := []string{} - for _, res := range results { - if strings.HasPrefix(strings.TrimSpace(res.content), "[") { - end := strings.Index(res.content, "]") - if end > 1 { - sections = append(sections, strings.TrimSpace(res.content[1:end])) - } - } - } - sections = uniqueStrings(sections) - return results, sections, nil -} - -func hashEntryID(file string, line int, content string) string { - sum := sha1.Sum([]byte(file + ":" + strconv.Itoa(line) + ":" + content)) - return hex.EncodeToString(sum[:8]) -} - -func tokenize(s string) []string { - normalized := strings.ToLower(s) - repl := []string{",", ".", ";", ":", "\n", "\t", "(", ")", "[", "]", "{", "}", "\"", "'", "`", "/", "\\", "|", "-", "_"} - for _, r := range repl { - normalized = strings.ReplaceAll(normalized, r, " ") - } - parts := strings.Fields(normalized) - return uniqueStrings(parts) -} - -func uniqueInt(in []int) []int { - if len(in) < 2 { - return in - } - out := in[:1] - for i := 1; i < len(in); i++ { - if in[i] != in[i-1] { - out = append(out, in[i]) - } - } - return out -} - -func uniqueStrings(in []string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, len(in)) - for _, v := range in { - v = strings.TrimSpace(v) - if v == "" { - continue - } - if _, ok := seen[v]; ok { - continue - } - seen[v] = struct{}{} - out = append(out, v) - } - sort.Strings(out) - return out -} diff --git a/pkg/tools/memory_namespace.go b/pkg/tools/memory_namespace.go index af0669f..86e2b84 100644 --- a/pkg/tools/memory_namespace.go +++ b/pkg/tools/memory_namespace.go @@ -2,7 +2,6 @@ package tools import ( "path/filepath" - "strings" ) func normalizeMemoryNamespace(in string) string { @@ -24,11 +23,3 @@ func memoryNamespaceBaseDir(workspace, namespace string) string { func parseMemoryNamespaceArg(args map[string]interface{}) string { return normalizeMemoryNamespace(MapStringArg(args, "namespace")) } - -func isPathUnder(parent, child string) bool { - rel, err := filepath.Rel(parent, child) - if err != nil { - return false - } - return !strings.HasPrefix(rel, "..") -} diff --git a/pkg/tools/repo_map.go b/pkg/tools/repo_map.go deleted file mode 100644 index 9eeba26..0000000 --- a/pkg/tools/repo_map.go +++ /dev/null @@ -1,307 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "time" -) - -type RepoMapTool struct { - workspace string -} - -func NewRepoMapTool(workspace string) *RepoMapTool { - return &RepoMapTool{workspace: workspace} -} - -func (t *RepoMapTool) Name() string { - return "repo_map" -} - -func (t *RepoMapTool) Description() string { - return "Build and query repository map to quickly locate target files/symbols before reading source." -} - -func (t *RepoMapTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "query": map[string]interface{}{ - "type": "string", - "description": "Search file path or symbol keyword", - }, - "max_results": map[string]interface{}{ - "type": "integer", - "default": 20, - "description": "Maximum results to return", - }, - "refresh": map[string]interface{}{ - "type": "boolean", - "description": "Force rebuild map cache", - "default": false, - }, - }, - } -} - -type repoMapCache struct { - Workspace string `json:"workspace"` - UpdatedAt int64 `json:"updated_at"` - Files []repoMapEntry `json:"files"` -} - -type repoMapEntry struct { - Path string `json:"path"` - Lang string `json:"lang"` - Size int64 `json:"size"` - ModTime int64 `json:"mod_time"` - Symbols []string `json:"symbols,omitempty"` -} - -func (t *RepoMapTool) Execute(_ context.Context, args map[string]interface{}) (string, error) { - query := MapStringArg(args, "query") - maxResults := MapIntArg(args, "max_results", 20) - forceRefresh, _ := MapBoolArg(args, "refresh") - - cache, err := t.loadOrBuildMap(forceRefresh) - if err != nil { - return "", err - } - if len(cache.Files) == 0 { - return "Repo map is empty.", nil - } - - results := t.filterRepoMap(cache.Files, query) - if len(results) > maxResults { - results = results[:maxResults] - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Repo Map (updated: %s)\n", time.UnixMilli(cache.UpdatedAt).Format("2006-01-02 15:04:05"))) - sb.WriteString(fmt.Sprintf("Workspace: %s\n", cache.Workspace)) - sb.WriteString(fmt.Sprintf("Matched files: %d\n\n", len(results))) - - for _, item := range results { - sb.WriteString(fmt.Sprintf("- %s [%s] (%d bytes)\n", item.Path, item.Lang, item.Size)) - if len(item.Symbols) > 0 { - sb.WriteString(fmt.Sprintf(" symbols: %s\n", strings.Join(item.Symbols, ", "))) - } - } - if len(results) == 0 { - return "No files matched query.", nil - } - return sb.String(), nil -} - -func (t *RepoMapTool) cachePath() string { - return filepath.Join(t.workspace, ".clawgo", "repo_map.json") -} - -func (t *RepoMapTool) loadOrBuildMap(force bool) (*repoMapCache, error) { - if !force { - if data, err := os.ReadFile(t.cachePath()); err == nil { - var cache repoMapCache - if err := json.Unmarshal(data, &cache); err == nil { - if cache.Workspace == t.workspace && (time.Now().UnixMilli()-cache.UpdatedAt) < int64((10*time.Minute)/time.Millisecond) { - return &cache, nil - } - } - } - } - - cache := &repoMapCache{ - Workspace: t.workspace, - UpdatedAt: time.Now().UnixMilli(), - Files: []repoMapEntry{}, - } - - err := filepath.Walk(t.workspace, func(path string, info os.FileInfo, err error) error { - if err != nil || info == nil { - return nil - } - if info.IsDir() { - name := info.Name() - if name == ".git" || name == "node_modules" || name == ".clawgo" || name == "vendor" { - return filepath.SkipDir - } - return nil - } - if info.Size() > 512*1024 { - return nil - } - - rel, err := filepath.Rel(t.workspace, path) - if err != nil { - return nil - } - lang := langFromPath(rel) - if lang == "" { - return nil - } - - entry := repoMapEntry{ - Path: rel, - Lang: lang, - Size: info.Size(), - ModTime: info.ModTime().UnixMilli(), - Symbols: extractSymbols(path, lang), - } - cache.Files = append(cache.Files, entry) - return nil - }) - if err != nil { - return nil, err - } - - sort.Slice(cache.Files, func(i, j int) bool { - return cache.Files[i].Path < cache.Files[j].Path - }) - - if err := os.MkdirAll(filepath.Dir(t.cachePath()), 0755); err == nil { - if data, err := json.Marshal(cache); err == nil { - _ = os.WriteFile(t.cachePath(), data, 0644) - } - } - return cache, nil -} - -func (t *RepoMapTool) filterRepoMap(files []repoMapEntry, query string) []repoMapEntry { - q := strings.ToLower(strings.TrimSpace(query)) - if q == "" { - return files - } - - type scored struct { - item repoMapEntry - score int - } - items := []scored{} - for _, file := range files { - score := 0 - p := strings.ToLower(file.Path) - if strings.Contains(p, q) { - score += 5 - } - for _, sym := range file.Symbols { - if strings.Contains(strings.ToLower(sym), q) { - score += 3 - } - } - if score > 0 { - items = append(items, scored{item: file, score: score}) - } - } - sort.Slice(items, func(i, j int) bool { - if items[i].score == items[j].score { - return items[i].item.Path < items[j].item.Path - } - return items[i].score > items[j].score - }) - - out := make([]repoMapEntry, 0, len(items)) - for _, item := range items { - out = append(out, item.item) - } - return out -} - -func langFromPath(path string) string { - switch strings.ToLower(filepath.Ext(path)) { - case ".go": - return "go" - case ".md": - return "markdown" - case ".json": - return "json" - case ".yaml", ".yml": - return "yaml" - case ".sh": - return "shell" - case ".py": - return "python" - case ".js": - return "javascript" - case ".ts": - return "typescript" - default: - return "" - } -} - -func extractSymbols(path, lang string) []string { - data, err := os.ReadFile(path) - if err != nil { - return nil - } - content := string(data) - out := []string{} - - switch lang { - case "go": - // Top-level functions - reFunc := regexp.MustCompile(`(?m)^func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`) - for _, m := range reFunc.FindAllStringSubmatch(content, 20) { - if len(m) > 1 { - out = append(out, m[1]) - } - } - // Methods: func (r *Receiver) MethodName(...) - reMethod := regexp.MustCompile(`(?m)^func\s+\([^)]+\)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`) - for _, m := range reMethod.FindAllStringSubmatch(content, 20) { - if len(m) > 1 { - out = append(out, m[1]) - } - } - // Types - reType := regexp.MustCompile(`(?m)^type\s+([A-Za-z_][A-Za-z0-9_]*)\s+`) - for _, m := range reType.FindAllStringSubmatch(content, 20) { - if len(m) > 1 { - out = append(out, m[1]) - } - } - case "python": - // Functions and Classes - re := regexp.MustCompile(`(?m)^(?:def|class)\s+([A-Za-z_][A-Za-z0-9_]*)`) - for _, m := range re.FindAllStringSubmatch(content, 30) { - if len(m) > 1 { - out = append(out, m[1]) - } - } - case "javascript", "typescript": - // function Name(...) or class Name ... or const Name = (...) => - re := regexp.MustCompile(`(?m)^(?:export\s+)?(?:function|class|const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)`) - for _, m := range re.FindAllStringSubmatch(content, 30) { - if len(m) > 1 { - out = append(out, m[1]) - } - } - case "markdown": - // Headers as symbols - re := regexp.MustCompile(`(?m)^#+\s+(.+)$`) - for _, m := range re.FindAllStringSubmatch(content, 20) { - if len(m) > 1 { - out = append(out, strings.TrimSpace(m[1])) - } - } - } - - if len(out) == 0 { - return nil - } - sort.Strings(out) - uniq := []string{} - seen := make(map[string]bool) - for _, s := range out { - if !seen[s] { - uniq = append(uniq, s) - seen[s] = true - } - } - return uniq -} diff --git a/pkg/tools/runtime_snapshot_test.go b/pkg/tools/runtime_snapshot_test.go index 508a12c..aede37c 100644 --- a/pkg/tools/runtime_snapshot_test.go +++ b/pkg/tools/runtime_snapshot_test.go @@ -9,30 +9,36 @@ import ( func TestSubagentManagerRuntimeSnapshot(t *testing.T) { workspace := t.TempDir() manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + manager.SetRunFunc(func(ctx context.Context, run *SubagentRun) (string, error) { return "snapshot-result", nil }) - task, err := manager.SpawnTask(context.Background(), SubagentSpawnOptions{ + run, err := manager.SpawnRun(context.Background(), SubagentSpawnOptions{ Task: "implement snapshot support", AgentID: "coder", OriginChannel: "cli", OriginChatID: "direct", }) if err != nil { - t.Fatalf("spawn task failed: %v", err) + t.Fatalf("spawn run failed: %v", err) } - if _, _, err := manager.WaitTask(context.Background(), task.ID); err != nil { - t.Fatalf("wait task failed: %v", err) + if _, _, err := manager.waitRun(context.Background(), run.ID); err != nil { + t.Fatalf("wait run failed: %v", err) } time.Sleep(10 * time.Millisecond) snapshot := manager.RuntimeSnapshot(20) - if len(snapshot.Tasks) == 0 || len(snapshot.Runs) == 0 { - t.Fatalf("expected runtime snapshot to include task and run records: %+v", snapshot) + if len(snapshot.Requests) == 0 || len(snapshot.Runs) == 0 { + t.Fatalf("expected runtime snapshot to include request and run records: %+v", snapshot) + } + if snapshot.Runs[0].RequestID == "" { + t.Fatalf("expected runtime snapshot run record to expose request_id: %+v", snapshot.Runs[0]) } if len(snapshot.Threads) == 0 || len(snapshot.Artifacts) == 0 { t.Fatalf("expected runtime snapshot to include thread and artifact records: %+v", snapshot) } msgArtifact := snapshot.Artifacts[0] + if msgArtifact.RequestID == "" { + t.Fatalf("expected runtime snapshot artifact to expose request_id, got %+v", msgArtifact) + } if msgArtifact.SourceType != "agent_message" { t.Fatalf("expected agent message artifact source type, got %+v", msgArtifact) } diff --git a/pkg/tools/runtime_types.go b/pkg/tools/runtime_types.go index 8a5a060..feac799 100644 --- a/pkg/tools/runtime_types.go +++ b/pkg/tools/runtime_types.go @@ -36,7 +36,7 @@ func (d DispatchDecision) Valid() bool { return strings.TrimSpace(d.TargetAgent) != "" && strings.TrimSpace(d.TaskText) != "" } -type TaskRecord struct { +type RequestRecord struct { ID string `json:"id"` ThreadID string `json:"thread_id,omitempty"` CorrelationID string `json:"correlation_id,omitempty"` @@ -51,7 +51,7 @@ type TaskRecord struct { type RunRecord struct { ID string `json:"id"` - TaskID string `json:"task_id,omitempty"` + RequestID string `json:"request_id,omitempty"` ThreadID string `json:"thread_id,omitempty"` CorrelationID string `json:"correlation_id,omitempty"` AgentID string `json:"agent_id,omitempty"` @@ -68,7 +68,7 @@ type RunRecord struct { type EventRecord struct { ID string `json:"id,omitempty"` RunID string `json:"run_id,omitempty"` - TaskID string `json:"task_id,omitempty"` + RequestID string `json:"request_id,omitempty"` AgentID string `json:"agent_id,omitempty"` Type string `json:"type"` Status string `json:"status,omitempty"` @@ -81,7 +81,7 @@ type EventRecord struct { type ArtifactRecord struct { ID string `json:"id,omitempty"` RunID string `json:"run_id,omitempty"` - TaskID string `json:"task_id,omitempty"` + RequestID string `json:"request_id,omitempty"` ThreadID string `json:"thread_id,omitempty"` Kind string `json:"kind,omitempty"` Name string `json:"name,omitempty"` @@ -109,7 +109,7 @@ type ThreadRecord struct { } type RuntimeSnapshot struct { - Tasks []TaskRecord `json:"tasks,omitempty"` + Requests []RequestRecord `json:"requests,omitempty"` Runs []RunRecord `json:"runs,omitempty"` Events []EventRecord `json:"events,omitempty"` Threads []ThreadRecord `json:"threads,omitempty"` @@ -118,7 +118,7 @@ type RuntimeSnapshot struct { type ExecutionRun struct { Run RunRecord `json:"run"` - Task TaskRecord `json:"task"` + Request RequestRecord `json:"request"` Decision DispatchDecision `json:"decision,omitempty"` } diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index eb2dcd8..bb077c4 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -2,7 +2,6 @@ package tools import ( "context" - "errors" "fmt" "os" "os/exec" @@ -81,12 +80,6 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (st cwd = wd } } - queueBase := strings.TrimSpace(t.workingDir) - if queueBase == "" { - queueBase = cwd - } - globalCommandWatchdog.setQueuePath(resolveCommandQueuePath(queueBase)) - if bg, _ := MapBoolArg(args, "background"); bg { if t.procManager == nil { return "", fmt.Errorf("background process manager not configured") @@ -117,38 +110,25 @@ func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd string) (s t.sandboxImage, "sh", "-c", command, } - policy := buildCommandRuntimePolicy(command, t.commandTickBase(command)) - var merged strings.Builder - for attempt := 0; attempt <= policy.MaxRestarts; attempt++ { - cmd := exec.CommandContext(ctx, "docker", dockerArgs...) - var stdout, stderr trackedOutput - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := runCommandWithDynamicTick(ctx, cmd, "exec:sandbox", command, policy.Difficulty, policy.BaseTick, policy.StallRoundLimit, func() int { - return stdout.Len() + stderr.Len() - }) - out := stdout.String() - if stderr.Len() > 0 { - out += "\nSTDERR:\n" + stderr.String() - } - if strings.TrimSpace(out) != "" { - if merged.Len() > 0 { - merged.WriteString("\n") - } - merged.WriteString(out) - } - if err == nil { - return merged.String(), nil - } - if errors.Is(err, ErrCommandNoProgress) && ctx.Err() == nil && attempt < policy.MaxRestarts { - merged.WriteString(fmt.Sprintf("\n[RESTART] no progress for %d ticks, restarting (%d/%d)\n", - policy.StallRoundLimit, attempt+1, policy.MaxRestarts)) - continue - } - merged.WriteString(fmt.Sprintf("\nSandbox Exit code: %v", err)) - return merged.String(), nil + cmd := exec.CommandContext(ctx, "docker", dockerArgs...) + var stdout, stderr trackedOutput + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + out := stdout.String() + if stderr.Len() > 0 { + out += "\nSTDERR:\n" + stderr.String() } - return merged.String(), nil + if err != nil { + if strings.TrimSpace(out) != "" { + out += "\n" + } + out += fmt.Sprintf("Sandbox Exit code: %v", err) + } + if strings.TrimSpace(out) == "" { + out = "(no output)" + } + return out, nil } func (t *ExecTool) SetTimeout(timeout time.Duration) { @@ -185,43 +165,22 @@ func (t *ExecTool) executeCommand(ctx context.Context, command, cwd string) (str } func (t *ExecTool) runShellCommand(ctx context.Context, command, cwd string) (string, error) { - policy := buildCommandRuntimePolicy(command, t.commandTickBase(command)) - var merged strings.Builder - for attempt := 0; attempt <= policy.MaxRestarts; attempt++ { - cmd := exec.CommandContext(ctx, "sh", "-c", command) - cmd.Env = buildExecEnv() - if cwd != "" { - cmd.Dir = cwd - } - - var stdout, stderr trackedOutput - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := runCommandWithDynamicTick(ctx, cmd, "exec", command, policy.Difficulty, policy.BaseTick, policy.StallRoundLimit, func() int { - return stdout.Len() + stderr.Len() - }) - out := stdout.String() - if stderr.Len() > 0 { - out += "\nSTDERR:\n" + stderr.String() - } - if strings.TrimSpace(out) != "" { - if merged.Len() > 0 { - merged.WriteString("\n") - } - merged.WriteString(out) - } - if err == nil { - return merged.String(), nil - } - if errors.Is(err, ErrCommandNoProgress) && ctx.Err() == nil && attempt < policy.MaxRestarts { - merged.WriteString(fmt.Sprintf("\n[RESTART] no progress for %d ticks, restarting (%d/%d)\n", - policy.StallRoundLimit, attempt+1, policy.MaxRestarts)) - continue - } - return merged.String(), err + cmd := exec.CommandContext(ctx, "sh", "-c", command) + cmd.Env = buildExecEnv() + if cwd != "" { + cmd.Dir = cwd } - return merged.String(), nil + + var stdout, stderr trackedOutput + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + out := stdout.String() + if stderr.Len() > 0 { + out += "\nSTDERR:\n" + stderr.String() + } + return out, err } func buildExecEnv() []string { @@ -235,70 +194,6 @@ func buildExecEnv() []string { return append(env, "PATH="+current+":"+fallback) } -func (t *ExecTool) commandTickBase(command string) time.Duration { - base := 2 * time.Second - if isHeavyCommand(command) { - base = 4 * time.Second - } - // Reuse configured timeout as a pacing hint (not a kill deadline). - if t.timeout > 0 { - derived := t.timeout / 30 - if derived > base { - base = derived - } - } - if base > 12*time.Second { - base = 12 * time.Second - } - return base -} - -func resolveCommandQueuePath(cwd string) string { - cwd = strings.TrimSpace(cwd) - if cwd == "" { - if wd, err := os.Getwd(); err == nil { - cwd = wd - } - } - if cwd == "" { - return "" - } - abs, err := filepath.Abs(cwd) - if err != nil { - return "" - } - return filepath.Join(abs, "memory", "task_queue.json") -} - -func isHeavyCommand(command string) bool { - cmd := strings.ToLower(strings.TrimSpace(command)) - if cmd == "" { - return false - } - heavyPatterns := []string{ - "docker build", - "docker compose build", - "go build", - "go test", - "npm install", - "npm ci", - "npm run build", - "pnpm install", - "pnpm build", - "yarn install", - "yarn build", - "cargo build", - "mvn package", - "gradle build", - } - for _, p := range heavyPatterns { - if strings.Contains(cmd, p) { - return true - } - } - return false -} - func detectMissingCommandFromOutput(output string) string { patterns := []*regexp.Regexp{ regexp.MustCompile(`(?m)(?:^|[:\s])([a-zA-Z0-9._+-]+): not found`), diff --git a/pkg/tools/shell_timeout_test.go b/pkg/tools/shell_timeout_test.go deleted file mode 100644 index 4cc3810..0000000 --- a/pkg/tools/shell_timeout_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package tools - -import ( - "testing" - "time" -) - -func TestIsHeavyCommand(t *testing.T) { - tests := []struct { - command string - heavy bool - }{ - {command: "docker build -t app .", heavy: true}, - {command: "docker compose build api", heavy: true}, - {command: "go test ./...", heavy: true}, - {command: "npm run build", heavy: true}, - {command: "echo hello", heavy: false}, - } - - for _, tt := range tests { - if got := isHeavyCommand(tt.command); got != tt.heavy { - t.Fatalf("isHeavyCommand(%q)=%v want %v", tt.command, got, tt.heavy) - } - } -} - -func TestCommandTickBase(t *testing.T) { - light := (&ExecTool{}).commandTickBase("echo hello") - heavy := (&ExecTool{}).commandTickBase("docker build -t app .") - if heavy <= light { - t.Fatalf("expected heavy command base tick > light, got heavy=%v light=%v", heavy, light) - } -} - -func TestNextCommandTick(t *testing.T) { - base := 2 * time.Second - t1 := nextCommandTick(base, 30*time.Second) - t2 := nextCommandTick(base, 5*time.Minute) - if t1 < base { - t.Fatalf("tick should not shrink below base: %v", t1) - } - if t2 <= t1 { - t.Fatalf("tick should grow with elapsed time: t1=%v t2=%v", t1, t2) - } - if t2 > 45*time.Second { - t.Fatalf("tick should be capped, got %v", t2) - } -} diff --git a/pkg/tools/skill_exec.go b/pkg/tools/skill_exec.go index cb4061f..7b82155 100644 --- a/pkg/tools/skill_exec.go +++ b/pkg/tools/skill_exec.go @@ -2,7 +2,6 @@ package tools import ( "context" - "errors" "fmt" "os" "os/exec" @@ -81,10 +80,6 @@ func (t *SkillExecTool) Execute(ctx context.Context, args map[string]interface{} t.writeAudit(skill, script, reason, callerAgent, callerScope, false, err.Error()) return "", err } - if strings.TrimSpace(t.workspace) != "" { - globalCommandWatchdog.setQueuePath(filepath.Join(strings.TrimSpace(t.workspace), "memory", "task_queue.json")) - } - skillDir, err := t.resolveSkillDir(skill) if err != nil { t.writeAudit(skill, script, reason, callerAgent, callerScope, false, err.Error()) @@ -121,48 +116,26 @@ func (t *SkillExecTool) Execute(ctx context.Context, args map[string]interface{} if len(cmdArgs) > 0 { commandLabel += " " + strings.Join(cmdArgs, " ") } - policy := buildCommandRuntimePolicy(commandLabel, 2*time.Second) - var merged strings.Builder - var runErr error - for attempt := 0; attempt <= policy.MaxRestarts; attempt++ { - cmd, err := buildSkillCommand(ctx, scriptPath, cmdArgs) - if err != nil { - t.writeAudit(skill, script, reason, callerAgent, callerScope, false, err.Error()) - return "", err - } - cmd.Dir = skillDir - var stdout, stderr trackedOutput - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err = runCommandWithDynamicTick(ctx, cmd, "skill_exec", commandLabel, policy.Difficulty, policy.BaseTick, policy.StallRoundLimit, func() int { - return stdout.Len() + stderr.Len() - }) - out := stdout.String() - if stderr.Len() > 0 { - out += "\nSTDERR:\n" + stderr.String() - } - if strings.TrimSpace(out) != "" { - if merged.Len() > 0 { - merged.WriteString("\n") - } - merged.WriteString(out) - } - if err == nil { - runErr = nil - break - } - runErr = err - if errors.Is(err, ErrCommandNoProgress) && ctx.Err() == nil && attempt < policy.MaxRestarts { - merged.WriteString(fmt.Sprintf("\n[RESTART] no progress for %d ticks, restarting (%d/%d)\n", - policy.StallRoundLimit, attempt+1, policy.MaxRestarts)) - continue - } - break + cmd, err := buildSkillCommand(ctx, scriptPath, cmdArgs) + if err != nil { + t.writeAudit(skill, script, reason, callerAgent, callerScope, false, err.Error()) + return "", err + } + cmd.Dir = skillDir + var stdout, stderr trackedOutput + cmd.Stdout = &stdout + cmd.Stderr = &stderr + runErr := cmd.Run() + output := stdout.String() + if stderr.Len() > 0 { + output += "\nSTDERR:\n" + stderr.String() } - output := merged.String() if runErr != nil { t.writeAudit(skill, script, reason, callerAgent, callerScope, false, runErr.Error()) - return "", fmt.Errorf("skill execution failed: %w\n%s", runErr, output) + if strings.TrimSpace(output) != "" { + return "", fmt.Errorf("skill execution failed: %w\n%s", runErr, output) + } + return "", fmt.Errorf("skill execution failed: %w", runErr) } out := strings.TrimSpace(output) diff --git a/pkg/tools/skill_exec_args_test.go b/pkg/tools/skill_exec_args_test.go index b469fb9..7c425b1 100644 --- a/pkg/tools/skill_exec_args_test.go +++ b/pkg/tools/skill_exec_args_test.go @@ -2,13 +2,21 @@ package tools import ( "context" + "os/exec" "os" "path/filepath" + "runtime" "strings" "testing" ) func TestSkillExecParsesStringArgsList(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skill_exec shell-script execution is not stable on Windows test environments") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash is not available in test environment") + } workspace := t.TempDir() skillDir := filepath.Join(workspace, "skills", "demo") scriptDir := filepath.Join(skillDir, "scripts") diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 00d0936..db61564 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -11,11 +11,10 @@ import ( "time" "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/ekg" "github.com/YspCoder/clawgo/pkg/providers" ) -type SubagentTask struct { +type SubagentRun struct { ID string `json:"id"` Task string `json:"task"` Label string `json:"label"` @@ -51,10 +50,10 @@ type SubagentTask struct { } type SubagentManager struct { - tasks map[string]*SubagentTask + runs map[string]*SubagentRun cancelFuncs map[string]context.CancelFunc waiters map[string]map[chan struct{}]struct{} - recoverableTaskIDs []string + recoverableRunIDs []string archiveAfterMinute int64 mu sync.RWMutex provider providers.LLMProvider @@ -65,7 +64,6 @@ type SubagentManager struct { profileStore *SubagentProfileStore runStore *SubagentRunStore mailboxStore *AgentMailboxStore - ekg *ekg.Engine } type SubagentSpawnOptions struct { @@ -91,7 +89,7 @@ func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *b runStore := NewSubagentRunStore(workspace) mailboxStore := NewAgentMailboxStore(workspace) mgr := &SubagentManager{ - tasks: make(map[string]*SubagentTask), + runs: make(map[string]*SubagentRun), cancelFuncs: make(map[string]context.CancelFunc), waiters: make(map[string]map[chan struct{}]struct{}), archiveAfterMinute: 60, @@ -102,41 +100,40 @@ func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *b profileStore: store, runStore: runStore, mailboxStore: mailboxStore, - ekg: ekg.New(workspace), } if runStore != nil { - for _, task := range runStore.List() { - mgr.tasks[task.ID] = task - if task.Status == RuntimeStatusRunning { - mgr.recoverableTaskIDs = append(mgr.recoverableTaskIDs, task.ID) + for _, run := range runStore.List() { + mgr.runs[run.ID] = run + if run.Status == RuntimeStatusRunning { + mgr.recoverableRunIDs = append(mgr.recoverableRunIDs, run.ID) } } mgr.nextID = runStore.NextIDSeed() } - go mgr.resumeRecoveredTasks() + go mgr.resumeRecoveredRuns() return mgr } func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) (string, error) { - task, err := sm.spawnTask(ctx, opts) + run, err := sm.spawnRun(ctx, opts) if err != nil { return "", err } - desc := fmt.Sprintf("Spawned subagent for task: %s (agent=%s)", task.Task, task.AgentID) - if task.Label != "" { - desc = fmt.Sprintf("Spawned subagent '%s' for task: %s (agent=%s)", task.Label, task.Task, task.AgentID) + desc := fmt.Sprintf("Spawned subagent for task: %s (agent=%s)", run.Task, run.AgentID) + if run.Label != "" { + desc = fmt.Sprintf("Spawned subagent '%s' for task: %s (agent=%s)", run.Label, run.Task, run.AgentID) } - if task.Role != "" { - desc += fmt.Sprintf(" role=%s", task.Role) + if run.Role != "" { + desc += fmt.Sprintf(" role=%s", run.Role) } return desc, nil } -func (sm *SubagentManager) SpawnTask(ctx context.Context, opts SubagentSpawnOptions) (*SubagentTask, error) { - return sm.spawnTask(ctx, opts) +func (sm *SubagentManager) SpawnRun(ctx context.Context, opts SubagentSpawnOptions) (*SubagentRun, error) { + return sm.spawnRun(ctx, opts) } -func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOptions) (*SubagentTask, error) { +func (sm *SubagentManager) spawnRun(ctx context.Context, opts SubagentSpawnOptions) (*SubagentRun, error) { task := strings.TrimSpace(opts.Task) if task == "" { return nil, fmt.Errorf("task is required") @@ -253,13 +250,13 @@ func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOpti sm.mu.Lock() defer sm.mu.Unlock() - taskID := fmt.Sprintf("subagent-%d", sm.nextID) + runID := fmt.Sprintf("subagent-%d", sm.nextID) sm.nextID++ - sessionKey := buildSubagentSessionKey(agentID, taskID) + sessionKey := buildSubagentSessionKey(agentID, runID) now := time.Now().UnixMilli() if correlationID == "" { - correlationID = taskID + correlationID = runID } if sm.mailboxStore != nil { thread, err := sm.mailboxStore.EnsureThread(AgentThread{ @@ -275,8 +272,8 @@ func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOpti threadID = thread.ThreadID } } - subagentTask := &SubagentTask{ - ID: taskID, + subagentRun := &SubagentRun{ + ID: runID, Task: task, Label: label, Role: role, @@ -305,9 +302,9 @@ func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOpti Updated: now, } taskCtx, cancel := context.WithCancel(ctx) - sm.tasks[taskID] = subagentTask - sm.cancelFuncs[taskID] = cancel - sm.recordMailboxMessageLocked(subagentTask, AgentMessage{ + sm.runs[runID] = subagentRun + sm.cancelFuncs[runID] = cancel + sm.recordMailboxMessageLocked(subagentRun, AgentMessage{ ThreadID: threadID, FromAgent: "main", ToAgent: agentID, @@ -318,117 +315,93 @@ func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOpti Status: "queued", CreatedAt: now, }) - sm.persistTaskLocked(subagentTask, "spawned", "") + sm.persistRunLocked(subagentRun, "spawned", "") - go sm.runTask(taskCtx, subagentTask) - return cloneSubagentTask(subagentTask), nil + go sm.runSubagent(taskCtx, subagentRun) + return cloneSubagentRun(subagentRun), nil } -func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { +func (sm *SubagentManager) runSubagent(ctx context.Context, run *SubagentRun) { defer func() { sm.mu.Lock() - delete(sm.cancelFuncs, task.ID) + delete(sm.cancelFuncs, run.ID) sm.mu.Unlock() }() sm.mu.Lock() - task.Status = RuntimeStatusRunning - task.Created = time.Now().UnixMilli() - task.Updated = task.Created - sm.persistTaskLocked(task, "started", "") + run.Status = RuntimeStatusRunning + run.Created = time.Now().UnixMilli() + run.Updated = run.Created + sm.persistRunLocked(run, "started", "") sm.mu.Unlock() - result, runErr := sm.runWithRetry(ctx, task) + result, runErr := sm.runWithRetry(ctx, run) sm.mu.Lock() if runErr != nil { - task.Status = RuntimeStatusFailed - task.Result = fmt.Sprintf("Error: %v", runErr) - task.Result = applySubagentResultQuota(task.Result, task.MaxResultChars) - task.Updated = time.Now().UnixMilli() - task.WaitingReply = false - sm.recordMailboxMessageLocked(task, AgentMessage{ - ThreadID: task.ThreadID, - FromAgent: task.AgentID, + run.Status = RuntimeStatusFailed + run.Result = fmt.Sprintf("Error: %v", runErr) + run.Result = applySubagentResultQuota(run.Result, run.MaxResultChars) + run.Updated = time.Now().UnixMilli() + run.WaitingReply = false + sm.recordMailboxMessageLocked(run, AgentMessage{ + ThreadID: run.ThreadID, + FromAgent: run.AgentID, ToAgent: "main", - ReplyTo: task.LastMessageID, - CorrelationID: task.CorrelationID, + ReplyTo: run.LastMessageID, + CorrelationID: run.CorrelationID, Type: "result", - Content: task.Result, + Content: run.Result, Status: "delivered", - CreatedAt: task.Updated, + CreatedAt: run.Updated, }) - sm.persistTaskLocked(task, "failed", task.Result) - sm.notifyTaskWaitersLocked(task.ID) + sm.persistRunLocked(run, "failed", run.Result) + sm.notifyRunWaitersLocked(run.ID) } else { - task.Status = RuntimeStatusCompleted - task.Result = applySubagentResultQuota(result, task.MaxResultChars) - task.Updated = time.Now().UnixMilli() - task.WaitingReply = false - sm.recordMailboxMessageLocked(task, AgentMessage{ - ThreadID: task.ThreadID, - FromAgent: task.AgentID, + run.Status = RuntimeStatusCompleted + run.Result = applySubagentResultQuota(result, run.MaxResultChars) + run.Updated = time.Now().UnixMilli() + run.WaitingReply = false + sm.recordMailboxMessageLocked(run, AgentMessage{ + ThreadID: run.ThreadID, + FromAgent: run.AgentID, ToAgent: "main", - ReplyTo: task.LastMessageID, - CorrelationID: task.CorrelationID, + ReplyTo: run.LastMessageID, + CorrelationID: run.CorrelationID, Type: "result", - Content: task.Result, + Content: run.Result, Status: "delivered", - CreatedAt: task.Updated, + CreatedAt: run.Updated, }) - sm.persistTaskLocked(task, "completed", task.Result) - sm.notifyTaskWaitersLocked(task.ID) + sm.persistRunLocked(run, "completed", run.Result) + sm.notifyRunWaitersLocked(run.ID) } sm.mu.Unlock() - sm.recordEKG(task, runErr) - // 2. Result broadcast - if sm.bus != nil && shouldNotifyMainOnFinal(task.NotifyMainPolicy, runErr, task) { - announceContent, notifyReason := buildSubagentMainNotification(task, runErr) + if sm.bus != nil && shouldNotifyMainOnFinal(run.NotifyMainPolicy, runErr, run) { + announceContent, notifyReason := buildSubagentMainNotification(run, runErr) sm.bus.PublishInbound(bus.InboundMessage{ Channel: "system", - SenderID: fmt.Sprintf("subagent:%s", task.ID), - ChatID: fmt.Sprintf("%s:%s", task.OriginChannel, task.OriginChatID), - SessionKey: task.SessionKey, + SenderID: fmt.Sprintf("subagent:%s", run.ID), + ChatID: fmt.Sprintf("%s:%s", run.OriginChannel, run.OriginChatID), + SessionKey: run.SessionKey, Content: announceContent, Metadata: map[string]string{ "trigger": "subagent", - "subagent_id": task.ID, - "agent_id": task.AgentID, - "role": task.Role, - "session_key": task.SessionKey, - "memory_ns": task.MemoryNS, - "retry_count": fmt.Sprintf("%d", task.RetryCount), - "timeout_sec": fmt.Sprintf("%d", task.TimeoutSec), - "status": task.Status, + "subagent_id": run.ID, + "agent_id": run.AgentID, + "role": run.Role, + "session_key": run.SessionKey, + "memory_ns": run.MemoryNS, + "retry_count": fmt.Sprintf("%d", run.RetryCount), + "timeout_sec": fmt.Sprintf("%d", run.TimeoutSec), + "status": run.Status, "notify_reason": notifyReason, }, }) } } -func (sm *SubagentManager) recordEKG(task *SubagentTask, runErr error) { - if sm == nil || sm.ekg == nil || task == nil { - return - } - status := "success" - logText := strings.TrimSpace(task.Result) - if runErr != nil { - status = "error" - if isBlockedSubagentError(runErr) { - logText = "blocked: " + strings.TrimSpace(task.Result) - } - } - sm.ekg.Record(ekg.Event{ - TaskID: task.ID, - Session: task.SessionKey, - Channel: task.OriginChannel, - Source: "subagent", - Status: status, - Log: logText, - }) -} - func normalizeNotifyMainPolicy(v string) string { switch strings.ToLower(strings.TrimSpace(v)) { case "", "final_only": @@ -440,7 +413,7 @@ func normalizeNotifyMainPolicy(v string) string { } } -func shouldNotifyMainOnFinal(policy string, runErr error, task *SubagentTask) bool { +func shouldNotifyMainOnFinal(policy string, runErr error, run *SubagentRun) bool { switch normalizeNotifyMainPolicy(policy) { case "internal_only": return false @@ -455,7 +428,7 @@ func shouldNotifyMainOnFinal(policy string, runErr error, task *SubagentTask) bo } } -func buildSubagentMainNotification(task *SubagentTask, runErr error) (string, string) { +func buildSubagentMainNotification(run *SubagentRun, runErr error) (string, string) { status := "completed" reason := "final" if runErr != nil { @@ -467,12 +440,12 @@ func buildSubagentMainNotification(task *SubagentTask, runErr error) (string, st } return fmt.Sprintf( "Subagent update\nagent: %s\nrun: %s\nstatus: %s\nreason: %s\ntask: %s\nsummary: %s", - strings.TrimSpace(task.AgentID), - strings.TrimSpace(task.ID), + strings.TrimSpace(run.AgentID), + strings.TrimSpace(run.ID), status, reason, - summarizeSubagentText(firstNonEmpty(task.Label, task.Task), 120), - summarizeSubagentText(task.Result, 280), + summarizeSubagentText(firstNonEmpty(run.Label, run.Task), 120), + summarizeSubagentText(run.Result, 280), ), reason } @@ -528,42 +501,35 @@ func firstNonEmpty(values ...string) string { return "" } -func (sm *SubagentManager) runWithRetry(ctx context.Context, task *SubagentTask) (string, error) { - maxRetries := normalizePositiveBound(task.MaxRetries, 0, 8) - backoffMs := normalizePositiveBound(task.RetryBackoff, 500, 120000) - timeoutSec := normalizePositiveBound(task.TimeoutSec, 0, 3600) +func (sm *SubagentManager) runWithRetry(ctx context.Context, run *SubagentRun) (string, error) { + maxRetries := normalizePositiveBound(run.MaxRetries, 0, 8) + backoffMs := normalizePositiveBound(run.RetryBackoff, 500, 120000) + timeoutSec := normalizePositiveBound(run.TimeoutSec, 0, 3600) var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { - result, err := runStringTaskWithTaskWatchdog( - ctx, - timeoutSec, - 2*time.Second, - stringTaskWatchdogOptions{ - ProgressFn: func() int { - return sm.taskWatchdogProgress(task) - }, - CanExtend: func() bool { - return sm.taskCanAutoExtend(task) - }, - }, - func(runCtx context.Context) (string, error) { - return sm.executeTaskOnce(runCtx, task) - }, - ) + runCtx := ctx + var cancel context.CancelFunc + if timeoutSec > 0 { + runCtx, cancel = context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second) + } + result, err := sm.executeRunOnce(runCtx, run) + if cancel != nil { + cancel() + } if err == nil { sm.mu.Lock() - task.RetryCount = attempt - task.Updated = time.Now().UnixMilli() - sm.persistTaskLocked(task, "attempt_succeeded", "") + run.RetryCount = attempt + run.Updated = time.Now().UnixMilli() + sm.persistRunLocked(run, "attempt_succeeded", "") sm.mu.Unlock() return result, nil } lastErr = err sm.mu.Lock() - task.RetryCount = attempt - task.Updated = time.Now().UnixMilli() - sm.persistTaskLocked(task, "attempt_failed", err.Error()) + run.RetryCount = attempt + run.Updated = time.Now().UnixMilli() + sm.persistRunLocked(run, "attempt_failed", err.Error()) sm.mu.Unlock() if attempt >= maxRetries { break @@ -575,47 +541,18 @@ func (sm *SubagentManager) runWithRetry(ctx context.Context, task *SubagentTask) } } if lastErr == nil { - lastErr = fmt.Errorf("subagent task failed with unknown error") + lastErr = fmt.Errorf("subagent run failed with unknown error") } return "", lastErr } -func (sm *SubagentManager) taskWatchdogProgress(task *SubagentTask) int { - if sm == nil || task == nil { - return 0 +func (sm *SubagentManager) executeRunOnce(ctx context.Context, run *SubagentRun) (string, error) { + if run == nil { + return "", fmt.Errorf("subagent run is nil") } - sm.mu.RLock() - defer sm.mu.RUnlock() - current, ok := sm.tasks[task.ID] - if !ok || current == nil { - current = task - } - if current.Updated <= 0 { - return 0 - } - return int(current.Updated) -} - -func (sm *SubagentManager) taskCanAutoExtend(task *SubagentTask) bool { - if sm == nil || task == nil { - return false - } - sm.mu.RLock() - defer sm.mu.RUnlock() - current, ok := sm.tasks[task.ID] - if !ok || current == nil { - current = task - } - return strings.EqualFold(strings.TrimSpace(current.Status), "running") -} - -func (sm *SubagentManager) executeTaskOnce(ctx context.Context, task *SubagentTask) (string, error) { - if task == nil { - return "", fmt.Errorf("subagent task is nil") - } - pending, consumedIDs := sm.consumeThreadInbox(task) + pending, consumedIDs := sm.consumeThreadInbox(run) if sm.runFunc != nil { - result, err := sm.runFunc(ctx, task) + result, err := sm.runFunc(ctx, run) if err != nil { sm.restoreMessageStatuses(consumedIDs) } else { @@ -628,7 +565,7 @@ func (sm *SubagentManager) executeTaskOnce(ctx context.Context, task *SubagentTa return "", fmt.Errorf("no llm provider configured for subagent execution") } - systemPrompt := sm.resolveSystemPrompt(task) + systemPrompt := sm.resolveSystemPrompt(run) messages := []providers.Message{ { Role: "system", @@ -636,7 +573,7 @@ func (sm *SubagentManager) executeTaskOnce(ctx context.Context, task *SubagentTa }, { Role: "user", - Content: task.Task, + Content: run.Task, }, } if strings.TrimSpace(pending) != "" { @@ -657,16 +594,16 @@ func (sm *SubagentManager) executeTaskOnce(ctx context.Context, task *SubagentTa return response.Content, nil } -func (sm *SubagentManager) resolveSystemPrompt(task *SubagentTask) string { +func (sm *SubagentManager) resolveSystemPrompt(run *SubagentRun) string { systemPrompt := "You are a subagent. Follow workspace AGENTS.md and complete the task independently." workspacePrompt := sm.readWorkspacePromptFile("AGENTS.md") if workspacePrompt != "" { systemPrompt = "Workspace policy (AGENTS.md):\n" + workspacePrompt + "\n\nComplete the given task independently and report the result." } - if task == nil { + if run == nil { return systemPrompt } - if promptFile := strings.TrimSpace(task.SystemPromptFile); promptFile != "" { + if promptFile := strings.TrimSpace(run.SystemPromptFile); promptFile != "" { if promptText := sm.readWorkspacePromptFile(promptFile); promptText != "" { return systemPrompt + "\n\nSubagent policy (" + promptFile + "):\n" + promptText } @@ -692,13 +629,13 @@ func (sm *SubagentManager) readWorkspacePromptFile(relPath string) string { return strings.TrimSpace(string(data)) } -type SubagentRunFunc func(ctx context.Context, task *SubagentTask) (string, error) +type SubagentRunFunc func(ctx context.Context, run *SubagentRun) (string, error) func (sm *SubagentManager) SetRunFunc(f SubagentRunFunc) { sm.mu.Lock() defer sm.mu.Unlock() sm.runFunc = f - go sm.resumeRecoveredTasks() + go sm.resumeRecoveredRuns() } func (sm *SubagentManager) ProfileStore() *SubagentProfileStore { @@ -707,7 +644,7 @@ func (sm *SubagentManager) ProfileStore() *SubagentProfileStore { return sm.profileStore } -func (sm *SubagentManager) resumeRecoveredTasks() { +func (sm *SubagentManager) resumeRecoveredRuns() { if sm == nil { return } @@ -716,194 +653,83 @@ func (sm *SubagentManager) resumeRecoveredTasks() { sm.mu.Unlock() return } - taskIDs := append([]string(nil), sm.recoverableTaskIDs...) - sm.recoverableTaskIDs = nil - toResume := make([]*SubagentTask, 0, len(taskIDs)) - for _, taskID := range taskIDs { - task, ok := sm.tasks[taskID] - if !ok || task == nil || task.Status != "running" { + runIDs := append([]string(nil), sm.recoverableRunIDs...) + sm.recoverableRunIDs = nil + toResume := make([]*SubagentRun, 0, len(runIDs)) + for _, runID := range runIDs { + run, ok := sm.runs[runID] + if !ok || run == nil || run.Status != "running" { continue } - task.Updated = time.Now().UnixMilli() - sm.persistTaskLocked(task, "recovered", "auto-resumed after restart") - toResume = append(toResume, task) + run.Updated = time.Now().UnixMilli() + sm.persistRunLocked(run, "recovered", "auto-resumed after restart") + toResume = append(toResume, run) } sm.mu.Unlock() - for _, task := range toResume { + for _, run := range toResume { taskCtx, cancel := context.WithCancel(context.Background()) sm.mu.Lock() - sm.cancelFuncs[task.ID] = cancel + sm.cancelFuncs[run.ID] = cancel sm.mu.Unlock() - go sm.runTask(taskCtx, task) + go sm.runSubagent(taskCtx, run) } } -func (sm *SubagentManager) NextTaskSequence() int { +func (sm *SubagentManager) NextRunSequence() int { sm.mu.RLock() defer sm.mu.RUnlock() return sm.nextID } -func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { - sm.mu.Lock() - defer sm.mu.Unlock() - sm.pruneArchivedLocked() - task, ok := sm.tasks[taskID] - if !ok && sm.runStore != nil { - return sm.runStore.Get(taskID) - } - return task, ok -} - -func (sm *SubagentManager) ListTasks() []*SubagentTask { +func (sm *SubagentManager) listRuns() []*SubagentRun { sm.mu.Lock() defer sm.mu.Unlock() sm.pruneArchivedLocked() - tasks := make([]*SubagentTask, 0, len(sm.tasks)) - seen := make(map[string]struct{}, len(sm.tasks)) - for _, task := range sm.tasks { - tasks = append(tasks, task) - seen[task.ID] = struct{}{} + runs := make([]*SubagentRun, 0, len(sm.runs)) + seen := make(map[string]struct{}, len(sm.runs)) + for _, run := range sm.runs { + runs = append(runs, run) + seen[run.ID] = struct{}{} } if sm.runStore != nil { - for _, task := range sm.runStore.List() { - if _, ok := seen[task.ID]; ok { + for _, run := range sm.runStore.List() { + if _, ok := seen[run.ID]; ok { continue } - tasks = append(tasks, task) + runs = append(runs, run) } } - return tasks + return runs } -func (sm *SubagentManager) KillTask(taskID string) bool { - sm.mu.Lock() - defer sm.mu.Unlock() - t, ok := sm.tasks[taskID] - if !ok { - return false - } - if cancel, ok := sm.cancelFuncs[taskID]; ok { - cancel() - delete(sm.cancelFuncs, taskID) - } - if !IsTerminalRuntimeStatus(t.Status) { - t.Status = RuntimeStatusCancelled - t.WaitingReply = false - t.Updated = time.Now().UnixMilli() - sm.persistTaskLocked(t, "killed", "") - sm.notifyTaskWaitersLocked(taskID) - } - return true -} - -func (sm *SubagentManager) SteerTask(taskID, message string) bool { - return sm.sendTaskMessage(taskID, "main", "control", message, false, "") -} - -func (sm *SubagentManager) SendTaskMessage(taskID, message string) bool { - return sm.sendTaskMessage(taskID, "main", "message", message, false, "") -} - -func (sm *SubagentManager) ReplyToTask(taskID, replyToMessageID, message string) bool { - return sm.sendTaskMessage(taskID, "main", "reply", message, false, replyToMessageID) -} - -func (sm *SubagentManager) AckTaskMessage(taskID, messageID string) bool { - sm.mu.Lock() - defer sm.mu.Unlock() - t, ok := sm.tasks[taskID] - if !ok { - return false - } - if sm.mailboxStore == nil { - return false - } - if strings.TrimSpace(messageID) == "" { - return false - } - t.Updated = time.Now().UnixMilli() - msg, err := sm.mailboxStore.UpdateMessageStatus(messageID, "acked", t.Updated) - if err != nil { - return false - } - t.LastMessageID = msg.MessageID - t.WaitingReply = false - sm.persistTaskLocked(t, "acked", messageID) - return true -} - -func (sm *SubagentManager) ResumeTask(ctx context.Context, taskID string) (string, bool) { - sm.mu.RLock() - t, ok := sm.tasks[taskID] - sm.mu.RUnlock() - if !ok { - return "", false - } - if strings.TrimSpace(t.Task) == "" { - return "", false - } - label := strings.TrimSpace(t.Label) - if label == "" { - label = "resumed" - } else { - label = label + "-resumed" - } - _, err := sm.Spawn(ctx, SubagentSpawnOptions{ - Task: t.Task, - Label: label, - Role: t.Role, - AgentID: t.AgentID, - MaxRetries: t.MaxRetries, - RetryBackoff: t.RetryBackoff, - TimeoutSec: t.TimeoutSec, - MaxTaskChars: t.MaxTaskChars, - MaxResultChars: t.MaxResultChars, - OriginChannel: t.OriginChannel, - OriginChatID: t.OriginChatID, - ThreadID: t.ThreadID, - CorrelationID: t.CorrelationID, - ParentRunID: t.ID, - }) - if err != nil { - return "", false - } - sm.mu.Lock() - if original, ok := sm.tasks[taskID]; ok { - sm.persistTaskLocked(original, "resumed", label) - } - sm.mu.Unlock() - return label, true -} - -func (sm *SubagentManager) Events(taskID string, limit int) ([]SubagentRunEvent, error) { +func (sm *SubagentManager) Events(runID string, limit int) ([]SubagentRunEvent, error) { if sm.runStore == nil { return nil, nil } - return sm.runStore.Events(taskID, limit) + return sm.runStore.Events(runID, limit) } func (sm *SubagentManager) RuntimeSnapshot(limit int) RuntimeSnapshot { if sm == nil { return RuntimeSnapshot{} } - tasks := sm.ListTasks() + runs := sm.listRuns() snapshot := RuntimeSnapshot{ - Tasks: make([]TaskRecord, 0, len(tasks)), - Runs: make([]RunRecord, 0, len(tasks)), + Requests: make([]RequestRecord, 0, len(runs)), + Runs: make([]RunRecord, 0, len(runs)), } seenThreads := map[string]struct{}{} - for _, task := range tasks { - snapshot.Tasks = append(snapshot.Tasks, taskToTaskRecord(task)) - snapshot.Runs = append(snapshot.Runs, taskToRunRecord(task)) - if evts, err := sm.Events(task.ID, limit); err == nil { + for _, run := range runs { + snapshot.Requests = append(snapshot.Requests, runToRequestRecord(run)) + snapshot.Runs = append(snapshot.Runs, runToRunRecord(run)) + if evts, err := sm.Events(run.ID, limit); err == nil { for _, evt := range evts { snapshot.Events = append(snapshot.Events, EventRecord{ ID: EventRecordID(evt.RunID, evt.Type, evt.At), RunID: evt.RunID, - TaskID: evt.RunID, + RequestID: evt.RunID, AgentID: evt.AgentID, Type: evt.Type, Status: evt.Status, @@ -913,7 +739,7 @@ func (sm *SubagentManager) RuntimeSnapshot(limit int) RuntimeSnapshot { }) } } - threadID := strings.TrimSpace(task.ThreadID) + threadID := strings.TrimSpace(run.ThreadID) if threadID == "" { continue } @@ -953,16 +779,6 @@ func (sm *SubagentManager) Inbox(agentID string, limit int) ([]AgentMessage, err return sm.mailboxStore.Inbox(agentID, limit) } -func (sm *SubagentManager) TaskInbox(taskID string, limit int) ([]AgentMessage, error) { - sm.mu.RLock() - task, ok := sm.tasks[taskID] - sm.mu.RUnlock() - if !ok || sm.mailboxStore == nil { - return nil, nil - } - return sm.mailboxStore.ThreadInbox(task.ThreadID, task.AgentID, limit) -} - func (sm *SubagentManager) Message(messageID string) (*AgentMessage, bool) { if sm.mailboxStore == nil { return nil, false @@ -975,12 +791,12 @@ func (sm *SubagentManager) pruneArchivedLocked() { return } cutoff := time.Now().Add(-time.Duration(sm.archiveAfterMinute) * time.Minute).UnixMilli() - for id, t := range sm.tasks { - if !IsTerminalRuntimeStatus(t.Status) { + for id, run := range sm.runs { + if !IsTerminalRuntimeStatus(run.Status) { continue } - if t.Updated > 0 && t.Updated < cutoff { - delete(sm.tasks, id) + if run.Updated > 0 && run.Updated < cutoff { + delete(sm.runs, id) delete(sm.cancelFuncs, id) } } @@ -1036,23 +852,23 @@ func normalizeSubagentIdentifier(in string) string { return out } -func buildSubagentSessionKey(agentID, taskID string) string { +func buildSubagentSessionKey(agentID, runID string) string { a := normalizeSubagentIdentifier(agentID) if a == "" { a = "default" } - t := normalizeSubagentIdentifier(taskID) + t := normalizeSubagentIdentifier(runID) if t == "" { - t = "task" + t = "run" } return fmt.Sprintf("subagent:%s:%s", a, t) } -func (sm *SubagentManager) persistTaskLocked(task *SubagentTask, eventType, message string) { - if task == nil || sm.runStore == nil { +func (sm *SubagentManager) persistRunLocked(run *SubagentRun, eventType, message string) { + if run == nil || sm.runStore == nil { return } - cp := cloneSubagentTask(task) + cp := cloneSubagentRun(run) _ = sm.runStore.AppendRun(cp) _ = sm.runStore.AppendEvent(SubagentRunEvent{ RunID: cp.ID, @@ -1065,13 +881,13 @@ func (sm *SubagentManager) persistTaskLocked(task *SubagentTask, eventType, mess }) } -func (sm *SubagentManager) WaitTask(ctx context.Context, taskID string) (*SubagentTask, bool, error) { +func (sm *SubagentManager) waitRun(ctx context.Context, runID string) (*SubagentRun, bool, error) { if sm == nil { return nil, false, fmt.Errorf("subagent manager not available") } - taskID = strings.TrimSpace(taskID) - if taskID == "" { - return nil, false, fmt.Errorf("task id is required") + runID = strings.TrimSpace(runID) + if runID == "" { + return nil, false, fmt.Errorf("run id is required") } if ctx == nil { ctx = context.Background() @@ -1079,29 +895,29 @@ func (sm *SubagentManager) WaitTask(ctx context.Context, taskID string) (*Subage ch := make(chan struct{}, 1) sm.mu.Lock() sm.pruneArchivedLocked() - task, ok := sm.tasks[taskID] + run, ok := sm.runs[runID] if !ok && sm.runStore != nil { - if persisted, found := sm.runStore.Get(taskID); found && persisted != nil { + if persisted, found := sm.runStore.Get(runID); found && persisted != nil { if IsTerminalRuntimeStatus(persisted.Status) { sm.mu.Unlock() return persisted, true, nil } } } - if ok && task != nil && IsTerminalRuntimeStatus(task.Status) { - cp := cloneSubagentTask(task) + if ok && run != nil && IsTerminalRuntimeStatus(run.Status) { + cp := cloneSubagentRun(run) sm.mu.Unlock() return cp, true, nil } - waiters := sm.waiters[taskID] + waiters := sm.waiters[runID] if waiters == nil { waiters = map[chan struct{}]struct{}{} - sm.waiters[taskID] = waiters + sm.waiters[runID] = waiters } waiters[ch] = struct{}{} sm.mu.Unlock() - defer sm.removeTaskWaiter(taskID, ch) + defer sm.removeRunWaiter(runID, ch) for { select { case <-ctx.Done(): @@ -1109,14 +925,14 @@ func (sm *SubagentManager) WaitTask(ctx context.Context, taskID string) (*Subage case <-ch: sm.mu.Lock() sm.pruneArchivedLocked() - task, ok := sm.tasks[taskID] - if ok && task != nil && IsTerminalRuntimeStatus(task.Status) { - cp := cloneSubagentTask(task) + run, ok := sm.runs[runID] + if ok && run != nil && IsTerminalRuntimeStatus(run.Status) { + cp := cloneSubagentRun(run) sm.mu.Unlock() return cp, true, nil } if !ok && sm.runStore != nil { - if persisted, found := sm.runStore.Get(taskID); found && persisted != nil && IsTerminalRuntimeStatus(persisted.Status) { + if persisted, found := sm.runStore.Get(runID); found && persisted != nil && IsTerminalRuntimeStatus(persisted.Status) { sm.mu.Unlock() return persisted, true, nil } @@ -1126,24 +942,24 @@ func (sm *SubagentManager) WaitTask(ctx context.Context, taskID string) (*Subage } } -func (sm *SubagentManager) removeTaskWaiter(taskID string, ch chan struct{}) { +func (sm *SubagentManager) removeRunWaiter(runID string, ch chan struct{}) { sm.mu.Lock() defer sm.mu.Unlock() - waiters := sm.waiters[taskID] + waiters := sm.waiters[runID] if len(waiters) == 0 { - delete(sm.waiters, taskID) + delete(sm.waiters, runID) return } delete(waiters, ch) if len(waiters) == 0 { - delete(sm.waiters, taskID) + delete(sm.waiters, runID) } } -func (sm *SubagentManager) notifyTaskWaitersLocked(taskID string) { - waiters := sm.waiters[taskID] +func (sm *SubagentManager) notifyRunWaitersLocked(runID string) { + waiters := sm.waiters[runID] if len(waiters) == 0 { - delete(sm.waiters, taskID) + delete(sm.waiters, runID) return } for ch := range waiters { @@ -1152,80 +968,31 @@ func (sm *SubagentManager) notifyTaskWaitersLocked(taskID string) { default: } } - delete(sm.waiters, taskID) + delete(sm.waiters, runID) } -func (sm *SubagentManager) recordMailboxMessageLocked(task *SubagentTask, msg AgentMessage) { - if sm.mailboxStore == nil || task == nil { +func (sm *SubagentManager) recordMailboxMessageLocked(run *SubagentRun, msg AgentMessage) { + if sm.mailboxStore == nil || run == nil { return } if strings.TrimSpace(msg.ThreadID) == "" { - msg.ThreadID = task.ThreadID + msg.ThreadID = run.ThreadID } stored, err := sm.mailboxStore.AppendMessage(msg) if err != nil { return } - task.LastMessageID = stored.MessageID + run.LastMessageID = stored.MessageID if stored.RequiresReply { - task.WaitingReply = true + run.WaitingReply = true } } -func (sm *SubagentManager) sendTaskMessage(taskID, fromAgent, msgType, message string, requiresReply bool, replyTo string) bool { - sm.mu.Lock() - defer sm.mu.Unlock() - t, ok := sm.tasks[taskID] - if !ok { - return false - } - message = strings.TrimSpace(message) - if message == "" { - return false - } - fromAgent = strings.TrimSpace(fromAgent) - if fromAgent == "" { - fromAgent = "main" - } - t.Updated = time.Now().UnixMilli() - if fromAgent == "main" { - t.Steering = append(t.Steering, message) - } - if strings.TrimSpace(replyTo) == "" { - replyTo = t.LastMessageID - } - toAgent := t.AgentID - if fromAgent != "main" { - toAgent = "main" - } - sm.recordMailboxMessageLocked(t, AgentMessage{ - ThreadID: t.ThreadID, - FromAgent: fromAgent, - ToAgent: toAgent, - ReplyTo: replyTo, - CorrelationID: t.CorrelationID, - Type: msgType, - Content: message, - RequiresReply: requiresReply, - Status: "queued", - CreatedAt: t.Updated, - }) - switch msgType { - case "control": - sm.persistTaskLocked(t, "steered", message) - case "reply": - sm.persistTaskLocked(t, "reply_sent", message) - default: - sm.persistTaskLocked(t, "message_sent", message) - } - return true -} - -func (sm *SubagentManager) consumeThreadInbox(task *SubagentTask) (string, []string) { - if task == nil || sm.mailboxStore == nil { +func (sm *SubagentManager) consumeThreadInbox(run *SubagentRun) (string, []string) { + if run == nil || sm.mailboxStore == nil { return "", nil } - msgs, err := sm.mailboxStore.ThreadInbox(task.ThreadID, task.AgentID, 0) + msgs, err := sm.mailboxStore.ThreadInbox(run.ThreadID, run.AgentID, 0) if err != nil || len(msgs) == 0 { return "", nil } diff --git a/pkg/tools/subagent_config_manager.go b/pkg/tools/subagent_config_manager.go deleted file mode 100644 index f4ac6e1..0000000 --- a/pkg/tools/subagent_config_manager.go +++ /dev/null @@ -1,223 +0,0 @@ -package tools - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/configops" - "github.com/YspCoder/clawgo/pkg/runtimecfg" -) - -func UpsertConfigSubagent(configPath string, args map[string]interface{}) (map[string]interface{}, error) { - configPath = strings.TrimSpace(configPath) - if configPath == "" { - return nil, fmt.Errorf("config path not configured") - } - agentID := stringArgFromMap(args, "agent_id") - if agentID == "" { - return nil, fmt.Errorf("agent_id is required") - } - cfg, err := config.LoadConfig(configPath) - if err != nil { - return nil, err - } - mainID := strings.TrimSpace(cfg.Agents.Router.MainAgentID) - if mainID == "" { - mainID = "main" - } - if cfg.Agents.Subagents == nil { - cfg.Agents.Subagents = map[string]config.SubagentConfig{} - } - subcfg := cfg.Agents.Subagents[agentID] - if enabled, ok := boolArgFromMap(args, "enabled"); ok { - if agentID == mainID && !enabled { - return nil, fmt.Errorf("main agent %q cannot be disabled", agentID) - } - subcfg.Enabled = enabled - } else if !subcfg.Enabled { - subcfg.Enabled = true - } - if v := stringArgFromMap(args, "role"); v != "" { - subcfg.Role = v - } - if v := stringArgFromMap(args, "transport"); v != "" { - subcfg.Transport = v - } - if v := stringArgFromMap(args, "node_id"); v != "" { - subcfg.NodeID = v - } - if v := stringArgFromMap(args, "parent_agent_id"); v != "" { - subcfg.ParentAgentID = v - } - if v := stringArgFromMap(args, "display_name"); v != "" { - subcfg.DisplayName = v - } - if v := stringArgFromMap(args, "notify_main_policy"); v != "" { - subcfg.NotifyMainPolicy = v - } - if v := stringArgFromMap(args, "description"); v != "" { - subcfg.Description = v - } - if v := stringArgFromMap(args, "system_prompt_file"); v != "" { - subcfg.SystemPromptFile = v - } - if v := stringArgFromMap(args, "memory_namespace"); v != "" { - subcfg.MemoryNamespace = v - } - if items := stringListArgFromMap(args, "tool_allowlist"); len(items) > 0 { - subcfg.Tools.Allowlist = items - } - if v := stringArgFromMap(args, "type"); v != "" { - subcfg.Type = v - } else if strings.TrimSpace(subcfg.Type) == "" { - subcfg.Type = "worker" - } - if strings.TrimSpace(subcfg.Transport) == "" { - subcfg.Transport = "local" - } - if subcfg.Enabled && strings.TrimSpace(subcfg.Transport) != "node" && strings.TrimSpace(subcfg.SystemPromptFile) == "" { - return nil, fmt.Errorf("system_prompt_file is required for enabled agent %q", agentID) - } - cfg.Agents.Subagents[agentID] = subcfg - if kws := stringListArgFromMap(args, "routing_keywords"); len(kws) > 0 { - cfg.Agents.Router.Rules = upsertRouteRuleConfig(cfg.Agents.Router.Rules, config.AgentRouteRule{ - AgentID: agentID, - Keywords: kws, - }) - } - if errs := config.Validate(cfg); len(errs) > 0 { - return nil, fmt.Errorf("config validation failed: %v", errs[0]) - } - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return nil, err - } - if _, err := configops.WriteConfigAtomicWithBackup(configPath, data); err != nil { - return nil, err - } - runtimecfg.Set(cfg) - return map[string]interface{}{ - "ok": true, - "agent_id": agentID, - "subagent": cfg.Agents.Subagents[agentID], - "rules": cfg.Agents.Router.Rules, - }, nil -} - -func DeleteConfigSubagent(configPath, agentID string) (map[string]interface{}, error) { - configPath = strings.TrimSpace(configPath) - if configPath == "" { - return nil, fmt.Errorf("config path not configured") - } - agentID = strings.TrimSpace(agentID) - if agentID == "" { - return nil, fmt.Errorf("agent_id is required") - } - cfg, err := config.LoadConfig(configPath) - if err != nil { - return nil, err - } - mainID := strings.TrimSpace(cfg.Agents.Router.MainAgentID) - if mainID == "" { - mainID = "main" - } - if agentID == mainID { - return nil, fmt.Errorf("main agent %q cannot be deleted", agentID) - } - if cfg.Agents.Subagents == nil { - return map[string]interface{}{"ok": false, "found": false, "agent_id": agentID}, nil - } - if _, ok := cfg.Agents.Subagents[agentID]; !ok { - return map[string]interface{}{"ok": false, "found": false, "agent_id": agentID}, nil - } - delete(cfg.Agents.Subagents, agentID) - cfg.Agents.Router.Rules = removeRouteRuleConfig(cfg.Agents.Router.Rules, agentID) - if errs := config.Validate(cfg); len(errs) > 0 { - return nil, fmt.Errorf("config validation failed: %v", errs[0]) - } - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return nil, err - } - if _, err := configops.WriteConfigAtomicWithBackup(configPath, data); err != nil { - return nil, err - } - runtimecfg.Set(cfg) - return map[string]interface{}{ - "ok": true, - "found": true, - "agent_id": agentID, - "rules": cfg.Agents.Router.Rules, - }, nil -} - -func stringArgFromMap(args map[string]interface{}, key string) string { - return MapStringArg(args, key) -} - -func boolArgFromMap(args map[string]interface{}, key string) (bool, bool) { - return MapBoolArg(args, key) -} - -func stringListArgFromMap(args map[string]interface{}, key string) []string { - return normalizeKeywords(MapStringListArg(args, key)) -} - -func upsertRouteRuleConfig(rules []config.AgentRouteRule, rule config.AgentRouteRule) []config.AgentRouteRule { - agentID := strings.TrimSpace(rule.AgentID) - if agentID == "" { - return rules - } - rule.Keywords = normalizeKeywords(rule.Keywords) - if len(rule.Keywords) == 0 { - return rules - } - out := make([]config.AgentRouteRule, 0, len(rules)+1) - replaced := false - for _, existing := range rules { - if strings.TrimSpace(existing.AgentID) == agentID { - out = append(out, rule) - replaced = true - continue - } - out = append(out, existing) - } - if !replaced { - out = append(out, rule) - } - return out -} - -func removeRouteRuleConfig(rules []config.AgentRouteRule, agentID string) []config.AgentRouteRule { - agentID = strings.TrimSpace(agentID) - if agentID == "" { - return rules - } - out := make([]config.AgentRouteRule, 0, len(rules)) - for _, existing := range rules { - if strings.TrimSpace(existing.AgentID) == agentID { - continue - } - out = append(out, existing) - } - return out -} - -func normalizeKeywords(items []string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, len(items)) - for _, item := range items { - item = strings.ToLower(strings.TrimSpace(item)) - if item == "" { - continue - } - if _, ok := seen[item]; ok { - continue - } - seen[item] = struct{}{} - out = append(out, item) - } - return out -} diff --git a/pkg/tools/subagent_config_tool.go b/pkg/tools/subagent_config_tool.go deleted file mode 100644 index 8352941..0000000 --- a/pkg/tools/subagent_config_tool.go +++ /dev/null @@ -1,110 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "sync" -) - -type SubagentConfigTool struct { - mu sync.RWMutex - configPath string -} - -func NewSubagentConfigTool(configPath string) *SubagentConfigTool { - return &SubagentConfigTool{configPath: strings.TrimSpace(configPath)} -} - -func (t *SubagentConfigTool) Name() string { return "subagent_config" } - -func (t *SubagentConfigTool) Description() string { - return "Persist subagent config and router rules into config.json." -} - -func (t *SubagentConfigTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "description": "upsert", - }, - "description": map[string]interface{}{ - "type": "string", - "description": "Natural-language worker description used by callers before upsert.", - }, - "agent_id_hint": map[string]interface{}{ - "type": "string", - "description": "Optional preferred agent id seed used by callers before upsert.", - }, - "agent_id": map[string]interface{}{"type": "string"}, - "transport": map[string]interface{}{"type": "string"}, - "node_id": map[string]interface{}{"type": "string"}, - "parent_agent_id": map[string]interface{}{"type": "string"}, - "role": map[string]interface{}{"type": "string"}, - "display_name": map[string]interface{}{"type": "string"}, - "system_prompt_file": map[string]interface{}{"type": "string"}, - "memory_namespace": map[string]interface{}{"type": "string"}, - "type": map[string]interface{}{"type": "string"}, - "tool_allowlist": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{"type": "string"}, - }, - "routing_keywords": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{"type": "string"}, - }, - }, - "required": []string{"action"}, - } -} - -func (t *SubagentConfigTool) SetConfigPath(path string) { - t.mu.Lock() - defer t.mu.Unlock() - t.configPath = strings.TrimSpace(path) -} - -func (t *SubagentConfigTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { - _ = ctx - switch stringArgFromMap(args, "action") { - case "upsert": - result, err := UpsertConfigSubagent(t.getConfigPath(), cloneSubagentConfigArgs(args)) - if err != nil { - return "", err - } - return marshalSubagentConfigPayload(result) - default: - return "", fmt.Errorf("unsupported action") - } -} - -func (t *SubagentConfigTool) getConfigPath() string { - t.mu.RLock() - defer t.mu.RUnlock() - return t.configPath -} - -func cloneSubagentConfigArgs(args map[string]interface{}) map[string]interface{} { - if args == nil { - return map[string]interface{}{} - } - out := make(map[string]interface{}, len(args)) - for k, v := range args { - if k == "action" || k == "agent_id_hint" { - continue - } - out[k] = v - } - return out -} - -func marshalSubagentConfigPayload(payload map[string]interface{}) (string, error) { - data, err := json.MarshalIndent(payload, "", " ") - if err != nil { - return "", err - } - return string(data), nil -} diff --git a/pkg/tools/subagent_config_tool_test.go b/pkg/tools/subagent_config_tool_test.go deleted file mode 100644 index 642d5df..0000000 --- a/pkg/tools/subagent_config_tool_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "path/filepath" - "testing" - - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/runtimecfg" -) - -func TestSubagentConfigToolUpsert(t *testing.T) { - workspace := t.TempDir() - configPath := filepath.Join(workspace, "config.json") - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - SystemPromptFile: "agents/main/AGENT.md", - } - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - tool := NewSubagentConfigTool(configPath) - out, err := tool.Execute(context.Background(), map[string]interface{}{ - "action": "upsert", - "agent_id": "reviewer", - "role": "testing", - "notify_main_policy": "internal_only", - "display_name": "Review Agent", - "description": "handles review and regression checks", - "system_prompt_file": "agents/reviewer/AGENT.md", - "routing_keywords": []interface{}{"review", "regression"}, - "tool_allowlist": []interface{}{"shell", "sessions"}, - }) - if err != nil { - t.Fatalf("upsert failed: %v", err) - } - var payload map[string]interface{} - if err := json.Unmarshal([]byte(out), &payload); err != nil { - t.Fatalf("unmarshal payload failed: %v", err) - } - if payload["ok"] != true { - t.Fatalf("expected ok payload, got %#v", payload) - } - reloaded, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("reload config failed: %v", err) - } - if reloaded.Agents.Subagents["reviewer"].DisplayName != "Review Agent" { - t.Fatalf("expected config to persist reviewer, got %+v", reloaded.Agents.Subagents["reviewer"]) - } - if reloaded.Agents.Subagents["reviewer"].NotifyMainPolicy != "internal_only" { - t.Fatalf("expected notify_main_policy to persist, got %+v", reloaded.Agents.Subagents["reviewer"]) - } - if len(reloaded.Agents.Router.Rules) == 0 { - t.Fatalf("expected router rules to persist") - } -} - -func TestSubagentConfigToolUpsertParsesStringAndCSVArgs(t *testing.T) { - workspace := t.TempDir() - configPath := filepath.Join(workspace, "config.json") - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - SystemPromptFile: "agents/main/AGENT.md", - } - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - tool := NewSubagentConfigTool(configPath) - _, err := tool.Execute(context.Background(), map[string]interface{}{ - "action": "upsert", - "agent_id": "reviewer", - "enabled": "true", - "role": "testing", - "system_prompt_file": "agents/reviewer/AGENT.md", - "routing_keywords": "review, regression", - "tool_allowlist": "shell, sessions", - }) - if err != nil { - t.Fatalf("upsert failed: %v", err) - } - - reloaded, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("reload config failed: %v", err) - } - subcfg := reloaded.Agents.Subagents["reviewer"] - if !subcfg.Enabled { - t.Fatalf("expected reviewer to be enabled, got %+v", subcfg) - } - if len(subcfg.Tools.Allowlist) != 2 { - t.Fatalf("expected allowlist to parse from csv, got %+v", subcfg.Tools.Allowlist) - } - if len(reloaded.Agents.Router.Rules) != 1 || len(reloaded.Agents.Router.Rules[0].Keywords) != 2 { - t.Fatalf("expected routing keywords to parse from csv, got %+v", reloaded.Agents.Router.Rules) - } -} diff --git a/pkg/tools/subagent_mailbox.go b/pkg/tools/subagent_mailbox.go index b5c9974..9774eeb 100644 --- a/pkg/tools/subagent_mailbox.go +++ b/pkg/tools/subagent_mailbox.go @@ -360,7 +360,7 @@ func messageToArtifactRecord(msg AgentMessage) ArtifactRecord { return ArtifactRecord{ ID: msg.MessageID, RunID: msg.CorrelationID, - TaskID: msg.CorrelationID, + RequestID: msg.CorrelationID, ThreadID: msg.ThreadID, Kind: "message", Name: msg.Type, diff --git a/pkg/tools/subagent_profile_test.go b/pkg/tools/subagent_profile_test.go deleted file mode 100644 index 18e16cd..0000000 --- a/pkg/tools/subagent_profile_test.go +++ /dev/null @@ -1,327 +0,0 @@ -package tools - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/nodes" - "github.com/YspCoder/clawgo/pkg/runtimecfg" -) - -func TestSubagentProfileStoreNormalization(t *testing.T) { - store := NewSubagentProfileStore(t.TempDir()) - saved, err := store.Upsert(SubagentProfile{ - AgentID: "Coder Agent", - Name: " ", - Role: "coding", - MemoryNamespace: "My Namespace", - ToolAllowlist: []string{" Read_File ", "memory_search", "READ_FILE"}, - Status: "ACTIVE", - }) - if err != nil { - t.Fatalf("upsert failed: %v", err) - } - - if saved.AgentID != "coder-agent" { - t.Fatalf("unexpected agent_id: %s", saved.AgentID) - } - if saved.Name != "coder-agent" { - t.Fatalf("unexpected default name: %s", saved.Name) - } - if saved.MemoryNamespace != "my-namespace" { - t.Fatalf("unexpected memory namespace: %s", saved.MemoryNamespace) - } - if len(saved.ToolAllowlist) != 2 { - t.Fatalf("unexpected allowlist size: %d (%v)", len(saved.ToolAllowlist), saved.ToolAllowlist) - } - for _, tool := range saved.ToolAllowlist { - if tool != strings.ToLower(tool) { - t.Fatalf("tool allowlist should be lowercase, got: %s", tool) - } - } -} - -func TestSubagentProfileToolCreateParsesStringNumericArgs(t *testing.T) { - store := NewSubagentProfileStore(t.TempDir()) - tool := NewSubagentProfileTool(store) - - out, err := tool.Execute(context.Background(), map[string]interface{}{ - "action": "create", - "agent_id": "reviewer", - "role": "testing", - "status": "active", - "system_prompt_file": "agents/reviewer/AGENT.md", - "tool_allowlist": "shell,sessions", - "max_retries": "2", - "retry_backoff_ms": "100", - "timeout_sec": "5", - }) - if err != nil { - t.Fatalf("create failed: %v", err) - } - if !strings.Contains(out, "Created subagent profile") { - t.Fatalf("unexpected output: %s", out) - } - - profile, ok, err := store.Get("reviewer") - if err != nil || !ok { - t.Fatalf("expected created profile, got ok=%v err=%v", ok, err) - } - if profile.MaxRetries != 2 || profile.TimeoutSec != 5 { - t.Fatalf("unexpected numeric fields: %+v", profile) - } - if len(profile.ToolAllowlist) != 2 { - t.Fatalf("unexpected allowlist: %+v", profile.ToolAllowlist) - } -} - -func TestSubagentManagerSpawnRejectsDisabledProfile(t *testing.T) { - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - return "ok", nil - }) - store := manager.ProfileStore() - if store == nil { - t.Fatalf("expected profile store to be available") - } - if _, err := store.Upsert(SubagentProfile{ - AgentID: "writer", - Status: "disabled", - }); err != nil { - t.Fatalf("failed to seed profile: %v", err) - } - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "Write docs", - AgentID: "writer", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err == nil { - t.Fatalf("expected disabled profile to block spawn") - } -} - -func TestSubagentManagerSpawnResolvesProfileByRole(t *testing.T) { - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - store := manager.ProfileStore() - if store == nil { - t.Fatalf("expected profile store to be available") - } - if _, err := store.Upsert(SubagentProfile{ - AgentID: "coder", - Role: "coding", - Status: "active", - ToolAllowlist: []string{"read_file"}, - }); err != nil { - t.Fatalf("failed to seed profile: %v", err) - } - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "Implement feature", - Role: "coding", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - - tasks := manager.ListTasks() - if len(tasks) != 1 { - t.Fatalf("expected one task, got %d", len(tasks)) - } - task := tasks[0] - if task.AgentID != "coder" { - t.Fatalf("expected agent_id to resolve to profile agent_id 'coder', got: %s", task.AgentID) - } - if task.Role != "coding" { - t.Fatalf("expected task role to remain 'coding', got: %s", task.Role) - } - if len(task.ToolAllowlist) != 1 || task.ToolAllowlist[0] != "read_file" { - t.Fatalf("expected allowlist from profile, got: %v", task.ToolAllowlist) - } - _ = waitSubagentDone(t, manager, 4*time.Second) -} - -func TestSubagentProfileStoreReadsProfilesFromRuntimeConfig(t *testing.T) { - runtimecfg.Set(config.DefaultConfig()) - t.Cleanup(func() { - runtimecfg.Set(config.DefaultConfig()) - }) - - cfg := config.DefaultConfig() - cfg.Agents.Subagents["coder"] = config.SubagentConfig{ - Enabled: true, - DisplayName: "Code Agent", - Role: "coding", - SystemPromptFile: "agents/coder/AGENT.md", - MemoryNamespace: "code-ns", - Tools: config.SubagentToolsConfig{ - Allowlist: []string{"read_file", "shell"}, - }, - Runtime: config.SubagentRuntimeConfig{ - MaxRetries: 2, - RetryBackoffMs: 2000, - TimeoutSec: 120, - MaxTaskChars: 4096, - MaxResultChars: 2048, - }, - } - runtimecfg.Set(cfg) - - store := NewSubagentProfileStore(t.TempDir()) - profile, ok, err := store.Get("coder") - if err != nil { - t.Fatalf("get failed: %v", err) - } - if !ok { - t.Fatalf("expected config-backed profile") - } - if profile.ManagedBy != "config.json" { - t.Fatalf("expected config ownership, got: %s", profile.ManagedBy) - } - if profile.Name != "Code Agent" || profile.Role != "coding" { - t.Fatalf("unexpected profile fields: %+v", profile) - } - if profile.SystemPromptFile != "agents/coder/AGENT.md" { - t.Fatalf("expected system_prompt_file from config, got: %s", profile.SystemPromptFile) - } - if len(profile.ToolAllowlist) != 2 { - t.Fatalf("expected merged allowlist, got: %v", profile.ToolAllowlist) - } -} - -func TestSubagentProfileStoreRejectsWritesForConfigManagedProfiles(t *testing.T) { - runtimecfg.Set(config.DefaultConfig()) - t.Cleanup(func() { - runtimecfg.Set(config.DefaultConfig()) - }) - - cfg := config.DefaultConfig() - cfg.Agents.Subagents["tester"] = config.SubagentConfig{ - Enabled: true, - Role: "test", - SystemPromptFile: "agents/tester/AGENT.md", - } - runtimecfg.Set(cfg) - - store := NewSubagentProfileStore(t.TempDir()) - if _, err := store.Upsert(SubagentProfile{AgentID: "tester"}); err == nil { - t.Fatalf("expected config-managed upsert to fail") - } - if err := store.Delete("tester"); err == nil { - t.Fatalf("expected config-managed delete to fail") - } -} - -func TestSubagentProfileStoreIncludesNodeMainBranchProfiles(t *testing.T) { - runtimecfg.Set(config.DefaultConfig()) - t.Cleanup(func() { - runtimecfg.Set(config.DefaultConfig()) - nodes.DefaultManager().Remove("edge-dev") - }) - - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Router.MainAgentID = "main" - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - SystemPromptFile: "agents/main/AGENT.md", - } - runtimecfg.Set(cfg) - - nodes.DefaultManager().Upsert(nodes.NodeInfo{ - ID: "edge-dev", - Name: "Edge Dev", - Online: true, - Agents: []nodes.AgentInfo{ - {ID: "main", DisplayName: "Main Agent", Role: "orchestrator", Type: "router"}, - {ID: "coder", DisplayName: "Code Agent", Role: "code", Type: "worker"}, - }, - Capabilities: nodes.Capabilities{ - Model: true, - }, - }) - - store := NewSubagentProfileStore(t.TempDir()) - profile, ok, err := store.Get(nodeBranchAgentID("edge-dev")) - if err != nil { - t.Fatalf("get failed: %v", err) - } - if !ok { - t.Fatalf("expected node-backed profile") - } - if profile.ManagedBy != "node_registry" || profile.Transport != "node" || profile.NodeID != "edge-dev" { - t.Fatalf("unexpected node profile: %+v", profile) - } - if profile.ParentAgentID != "main" { - t.Fatalf("expected main parent agent, got %+v", profile) - } - childProfile, ok, err := store.Get("node.edge-dev.coder") - if err != nil { - t.Fatalf("get child profile failed: %v", err) - } - if !ok { - t.Fatalf("expected child node-backed profile") - } - if childProfile.ManagedBy != "node_registry" || childProfile.Transport != "node" || childProfile.NodeID != "edge-dev" { - t.Fatalf("unexpected child node profile: %+v", childProfile) - } - if childProfile.ParentAgentID != "node.edge-dev.main" { - t.Fatalf("expected child profile to attach to remote main, got %+v", childProfile) - } - if _, err := store.Upsert(SubagentProfile{AgentID: profile.AgentID}); err == nil { - t.Fatalf("expected node-managed upsert to fail") - } - if err := store.Delete(profile.AgentID); err == nil { - t.Fatalf("expected node-managed delete to fail") - } -} - -func TestSubagentProfileStoreExcludesLocalNodeMainBranchProfile(t *testing.T) { - runtimecfg.Set(config.DefaultConfig()) - t.Cleanup(func() { - runtimecfg.Set(config.DefaultConfig()) - nodes.DefaultManager().Remove("local") - }) - - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Router.MainAgentID = "main" - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - SystemPromptFile: "agents/main/AGENT.md", - } - runtimecfg.Set(cfg) - - nodes.DefaultManager().Upsert(nodes.NodeInfo{ - ID: "local", - Name: "Local", - Online: true, - }) - - store := NewSubagentProfileStore(t.TempDir()) - if profile, ok, err := store.Get(nodeBranchAgentID("local")); err != nil { - t.Fatalf("get failed: %v", err) - } else if ok { - t.Fatalf("expected local node branch profile to be excluded, got %+v", profile) - } - - items, err := store.List() - if err != nil { - t.Fatalf("list failed: %v", err) - } - for _, item := range items { - if item.AgentID == nodeBranchAgentID("local") { - t.Fatalf("local node branch profile should not appear in list") - } - } -} diff --git a/pkg/tools/subagent_router.go b/pkg/tools/subagent_router.go index c00bd10..9b40865 100644 --- a/pkg/tools/subagent_router.go +++ b/pkg/tools/subagent_router.go @@ -27,7 +27,7 @@ type RouterDispatchRequest struct { } type RouterReply struct { - TaskID string + RunID string ThreadID string CorrelationID string AgentID string @@ -45,7 +45,7 @@ func NewSubagentRouter(manager *SubagentManager) *SubagentRouter { return &SubagentRouter{manager: manager} } -func (r *SubagentRouter) DispatchTask(ctx context.Context, req RouterDispatchRequest) (*SubagentTask, error) { +func (r *SubagentRouter) DispatchRun(ctx context.Context, req RouterDispatchRequest) (*SubagentRun, error) { if r == nil || r.manager == nil { return nil, fmt.Errorf("subagent router is not configured") } @@ -57,7 +57,7 @@ func (r *SubagentRouter) DispatchTask(ctx context.Context, req RouterDispatchReq req.Task = strings.TrimSpace(req.Decision.TaskText) } } - task, err := r.manager.SpawnTask(ctx, SubagentSpawnOptions{ + run, err := r.manager.SpawnRun(ctx, SubagentSpawnOptions{ Task: req.Task, Label: req.Label, Role: req.Role, @@ -77,34 +77,34 @@ func (r *SubagentRouter) DispatchTask(ctx context.Context, req RouterDispatchReq if err != nil { return nil, err } - return task, nil + return run, nil } -func (r *SubagentRouter) WaitReply(ctx context.Context, taskID string, interval time.Duration) (*RouterReply, error) { +func (r *SubagentRouter) WaitRun(ctx context.Context, runID string, interval time.Duration) (*RouterReply, error) { if r == nil || r.manager == nil { return nil, fmt.Errorf("subagent router is not configured") } _ = interval - taskID = strings.TrimSpace(taskID) - if taskID == "" { - return nil, fmt.Errorf("task id is required") + runID = strings.TrimSpace(runID) + if runID == "" { + return nil, fmt.Errorf("run id is required") } - task, ok, err := r.manager.WaitTask(ctx, taskID) + run, ok, err := r.manager.waitRun(ctx, runID) if err != nil { return nil, err } - if !ok || task == nil { + if !ok || run == nil { return nil, fmt.Errorf("subagent not found") } return &RouterReply{ - TaskID: task.ID, - ThreadID: task.ThreadID, - CorrelationID: task.CorrelationID, - AgentID: task.AgentID, - Status: task.Status, - Result: strings.TrimSpace(task.Result), - Run: taskToRunRecord(task), - Error: taskRuntimeError(task), + RunID: run.ID, + ThreadID: run.ThreadID, + CorrelationID: run.CorrelationID, + AgentID: run.AgentID, + Status: run.Status, + Result: strings.TrimSpace(run.Result), + Run: runToRunRecord(run), + Error: runRuntimeError(run), }, nil } @@ -117,7 +117,7 @@ func (r *SubagentRouter) MergeResults(replies []*RouterReply) string { if reply == nil { continue } - sb.WriteString(fmt.Sprintf("[%s] agent=%s status=%s\n", reply.TaskID, reply.AgentID, reply.Status)) + sb.WriteString(fmt.Sprintf("[%s] agent=%s status=%s\n", reply.RunID, reply.AgentID, reply.Status)) if txt := strings.TrimSpace(reply.Result); txt != "" { sb.WriteString(txt) } else { diff --git a/pkg/tools/subagent_router_test.go b/pkg/tools/subagent_router_test.go index cde693e..bd83f2c 100644 --- a/pkg/tools/subagent_router_test.go +++ b/pkg/tools/subagent_router_test.go @@ -7,15 +7,15 @@ import ( "time" ) -func TestSubagentRouterDispatchAndWaitReply(t *testing.T) { +func TestSubagentRouterDispatchAndWaitRun(t *testing.T) { workspace := t.TempDir() manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + manager.SetRunFunc(func(ctx context.Context, run *SubagentRun) (string, error) { return "router-result", nil }) router := NewSubagentRouter(manager) - task, err := router.DispatchTask(context.Background(), RouterDispatchRequest{ + run, err := router.DispatchRun(context.Background(), RouterDispatchRequest{ Task: "implement feature", AgentID: "coder", OriginChannel: "cli", @@ -24,13 +24,13 @@ func TestSubagentRouterDispatchAndWaitReply(t *testing.T) { if err != nil { t.Fatalf("dispatch failed: %v", err) } - if task.ThreadID == "" { + if run.ThreadID == "" { t.Fatalf("expected thread id on dispatched task") } - reply, err := router.WaitReply(context.Background(), task.ID, 20*time.Millisecond) + reply, err := router.WaitRun(context.Background(), run.ID, 20*time.Millisecond) if err != nil { - t.Fatalf("wait reply failed: %v", err) + t.Fatalf("wait run failed: %v", err) } if reply.Status != "completed" || reply.Result != "router-result" { t.Fatalf("unexpected reply: %+v", reply) @@ -40,24 +40,24 @@ func TestSubagentRouterDispatchAndWaitReply(t *testing.T) { func TestSubagentRouterMergeResults(t *testing.T) { router := NewSubagentRouter(nil) out := router.MergeResults([]*RouterReply{ - {TaskID: "subagent-1", AgentID: "coder", Status: "completed", Result: "done"}, - {TaskID: "subagent-2", AgentID: "tester", Status: "failed", Result: "boom"}, + {RunID: "subagent-1", AgentID: "coder", Status: "completed", Result: "done"}, + {RunID: "subagent-2", AgentID: "tester", Status: "failed", Result: "boom"}, }) if !strings.Contains(out, "subagent-1") || !strings.Contains(out, "agent=tester") { t.Fatalf("unexpected merged output: %s", out) } } -func TestSubagentRouterWaitReplyContextCancel(t *testing.T) { +func TestSubagentRouterWaitRunContextCancel(t *testing.T) { workspace := t.TempDir() manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + manager.SetRunFunc(func(ctx context.Context, run *SubagentRun) (string, error) { <-ctx.Done() return "", ctx.Err() }) router := NewSubagentRouter(manager) - task, err := router.DispatchTask(context.Background(), RouterDispatchRequest{ + run, err := router.DispatchRun(context.Background(), RouterDispatchRequest{ Task: "long task", AgentID: "coder", OriginChannel: "cli", @@ -69,7 +69,7 @@ func TestSubagentRouterWaitReplyContextCancel(t *testing.T) { waitCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() - if _, err := router.WaitReply(waitCtx, task.ID, 20*time.Millisecond); err == nil { + if _, err := router.WaitRun(waitCtx, run.ID, 20*time.Millisecond); err == nil { t.Fatalf("expected context cancellation error") } } diff --git a/pkg/tools/subagent_runtime_control_test.go b/pkg/tools/subagent_runtime_control_test.go deleted file mode 100644 index f2761c5..0000000 --- a/pkg/tools/subagent_runtime_control_test.go +++ /dev/null @@ -1,747 +0,0 @@ -package tools - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/providers" -) - -func TestSubagentSpawnEnforcesTaskQuota(t *testing.T) { - t.Parallel() - - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - return "ok", nil - }) - store := manager.ProfileStore() - if store == nil { - t.Fatalf("expected profile store") - } - if _, err := store.Upsert(SubagentProfile{ - AgentID: "coder", - MaxTaskChars: 8, - }); err != nil { - t.Fatalf("failed to create profile: %v", err) - } - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "this task is too long", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err == nil { - t.Fatalf("expected max_task_chars quota to reject spawn") - } -} - -func TestSubagentRunWithRetryEventuallySucceeds(t *testing.T) { - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - attempts := 0 - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - attempts++ - if attempts == 1 { - return "", errors.New("temporary failure") - } - return "retry success", nil - }) - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "retry task", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - MaxRetries: 1, - RetryBackoff: 1, - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - - task := waitSubagentDone(t, manager, 4*time.Second) - if task.Status != "completed" { - t.Fatalf("expected completed task, got %s (%s)", task.Status, task.Result) - } - if task.RetryCount != 1 { - t.Fatalf("expected retry_count=1, got %d", task.RetryCount) - } - if attempts < 2 { - t.Fatalf("expected at least 2 attempts, got %d", attempts) - } -} - -func TestSubagentRunAutoExtendsWhileStillRunning(t *testing.T) { - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-time.After(2 * time.Second): - return "completed after extension", nil - } - }) - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "timeout task", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - TimeoutSec: 1, - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - - task := waitSubagentDone(t, manager, 4*time.Second) - if task.Status != "completed" { - t.Fatalf("expected completed task after watchdog extension, got %s", task.Status) - } - if task.RetryCount != 0 { - t.Fatalf("expected retry_count=0, got %d", task.RetryCount) - } - if !strings.Contains(task.Result, "completed after extension") { - t.Fatalf("expected extended result, got %q", task.Result) - } -} - -func TestSubagentBroadcastIncludesFailureStatus(t *testing.T) { - workspace := t.TempDir() - msgBus := bus.NewMessageBus() - defer msgBus.Close() - - manager := NewSubagentManager(nil, workspace, msgBus) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - return "", errors.New("boom") - }) - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "failing task", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - - task := waitSubagentDone(t, manager, 4*time.Second) - if task.Status != "failed" { - t.Fatalf("expected failed task, got %s", task.Status) - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - msg, ok := msgBus.ConsumeInbound(ctx) - if !ok { - t.Fatalf("expected subagent completion message") - } - if got := strings.TrimSpace(msg.Metadata["status"]); got != "failed" { - t.Fatalf("expected metadata status=failed, got %q", got) - } - if !strings.Contains(strings.ToLower(msg.Content), "status: failed") { - t.Fatalf("expected structured failure status in content, got %q", msg.Content) - } - if got := strings.TrimSpace(msg.Metadata["notify_reason"]); got != "final" { - t.Fatalf("expected notify_reason=final, got %q", got) - } -} - -func TestSubagentManagerRestoresPersistedRuns(t *testing.T) { - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - return "persisted", nil - }) - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "persist task", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - - task := waitSubagentDone(t, manager, 4*time.Second) - if task.Status != "completed" { - t.Fatalf("expected completed task, got %s", task.Status) - } - - reloaded := NewSubagentManager(nil, workspace, nil) - got, ok := reloaded.GetTask(task.ID) - if !ok { - t.Fatalf("expected persisted task to reload") - } - if got.Status != "completed" || got.Result != "persisted" { - t.Fatalf("unexpected restored task: %+v", got) - } - - _, err = reloaded.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "second task", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn after reload failed: %v", err) - } - tasks := reloaded.ListTasks() - found := false - for _, item := range tasks { - if item.ID == "subagent-2" { - found = true - break - } - } - if !found { - t.Fatalf("expected nextID seed to continue from persisted runs, got %+v", tasks) - } - _ = waitSubagentDone(t, reloaded, 4*time.Second) - time.Sleep(100 * time.Millisecond) -} - -func TestSubagentManagerInternalOnlySuppressesMainNotification(t *testing.T) { - workspace := t.TempDir() - msgBus := bus.NewMessageBus() - manager := NewSubagentManager(nil, workspace, msgBus) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - return "silent-result", nil - }) - store := manager.ProfileStore() - if store == nil { - t.Fatalf("expected profile store") - } - if _, err := store.Upsert(SubagentProfile{ - AgentID: "coder", - Name: "Code Agent", - NotifyMainPolicy: "internal_only", - SystemPromptFile: "agents/coder/AGENT.md", - Status: "active", - }); err != nil { - t.Fatalf("profile upsert failed: %v", err) - } - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "internal-only task", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - task := waitSubagentDone(t, manager, 4*time.Second) - if task.Status != "completed" { - t.Fatalf("expected completed task, got %s", task.Status) - } - - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - if msg, ok := msgBus.ConsumeInbound(ctx); ok { - t.Fatalf("did not expect main notification, got %+v", msg) - } -} - -func TestSubagentManagerOnBlockedNotifiesOnlyBlockedFailures(t *testing.T) { - workspace := t.TempDir() - msgBus := bus.NewMessageBus() - manager := NewSubagentManager(nil, workspace, msgBus) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - switch task.Task { - case "blocked-task": - return "", errors.New("command tick timeout exceeded: 600s") - default: - return "done", nil - } - }) - store := manager.ProfileStore() - if store == nil { - t.Fatalf("expected profile store") - } - if _, err := store.Upsert(SubagentProfile{ - AgentID: "pm", - Name: "Product Manager", - NotifyMainPolicy: "on_blocked", - SystemPromptFile: "agents/pm/AGENT.md", - Status: "active", - }); err != nil { - t.Fatalf("profile upsert failed: %v", err) - } - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "successful-task", - AgentID: "pm", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn success case failed: %v", err) - } - _ = waitSubagentDone(t, manager, 4*time.Second) - - ctxSilent, cancelSilent := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancelSilent() - if msg, ok := msgBus.ConsumeInbound(ctxSilent); ok { - t.Fatalf("did not expect success notification for on_blocked, got %+v", msg) - } - - _, err = manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "blocked-task", - AgentID: "pm", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn blocked case failed: %v", err) - } - _ = waitSubagentDone(t, manager, 4*time.Second) - - ctxBlocked, cancelBlocked := context.WithTimeout(context.Background(), 2*time.Second) - defer cancelBlocked() - msg, ok := msgBus.ConsumeInbound(ctxBlocked) - if !ok { - t.Fatalf("expected blocked notification") - } - if got := strings.TrimSpace(msg.Metadata["notify_reason"]); got != "blocked" { - t.Fatalf("expected notify_reason=blocked, got %q", got) - } - if !strings.Contains(strings.ToLower(msg.Content), "blocked") { - t.Fatalf("expected blocked wording in content, got %q", msg.Content) - } -} - -func TestSubagentManagerRecordsFailuresToEKG(t *testing.T) { - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - return "", errors.New("rate limit exceeded") - }) - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "ekg failure", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - _ = waitSubagentDone(t, manager, 4*time.Second) - - data, err := os.ReadFile(filepath.Join(workspace, "memory", "ekg-events.jsonl")) - if err != nil { - t.Fatalf("expected ekg events to be written: %v", err) - } - text := string(data) - if !strings.Contains(text, "\"source\":\"subagent\"") { - t.Fatalf("expected subagent source in ekg log, got %s", text) - } - if !strings.Contains(text, "\"status\":\"error\"") { - t.Fatalf("expected error status in ekg log, got %s", text) - } - if !strings.Contains(strings.ToLower(text), "rate limit exceeded") { - t.Fatalf("expected failure text in ekg log, got %s", text) - } -} - -func TestSubagentManagerAutoRecoversRunningTaskAfterRestart(t *testing.T) { - workspace := t.TempDir() - block := make(chan struct{}) - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - <-block - return "should-not-complete-here", nil - }) - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "recover me", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - time.Sleep(80 * time.Millisecond) - - recovered := make(chan string, 1) - reloaded := NewSubagentManager(nil, workspace, nil) - reloaded.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - recovered <- task.ID - return "recovered-ok", nil - }) - - select { - case taskID := <-recovered: - if taskID != "subagent-1" { - t.Fatalf("expected recovered task id subagent-1, got %s", taskID) - } - case <-time.After(2 * time.Second): - t.Fatalf("expected running task to auto-recover after restart") - } - - _ = waitSubagentDone(t, reloaded, 4*time.Second) - got, ok := reloaded.GetTask("subagent-1") - if !ok { - t.Fatalf("expected recovered task to exist") - } - if got.Status != "completed" || got.Result != "recovered-ok" { - t.Fatalf("unexpected recovered task: %+v", got) - } - - close(block) - _ = waitSubagentDone(t, manager, 4*time.Second) - time.Sleep(100 * time.Millisecond) -} - -func TestSubagentManagerPersistsEvents(t *testing.T) { - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - time.Sleep(100 * time.Millisecond) - return "ok", nil - }) - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "event task", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - - time.Sleep(20 * time.Millisecond) - if !manager.SteerTask("subagent-1", "focus on tests") { - t.Fatalf("expected steer to succeed") - } - task := waitSubagentDone(t, manager, 4*time.Second) - events, err := manager.Events(task.ID, 0) - if err != nil { - t.Fatalf("events failed: %v", err) - } - if len(events) == 0 { - t.Fatalf("expected persisted events") - } - hasSteer := false - for _, evt := range events { - if evt.Type == "steered" { - hasSteer = true - break - } - } - if !hasSteer { - t.Fatalf("expected steered event, got %+v", events) - } -} - -func TestSubagentMailboxStoresThreadAndReplies(t *testing.T) { - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - return "done", nil - }) - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "implement feature", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - - task := waitSubagentDone(t, manager, 4*time.Second) - if task.ThreadID == "" { - t.Fatalf("expected thread id") - } - thread, ok := manager.Thread(task.ThreadID) - if !ok { - t.Fatalf("expected thread to exist") - } - if thread.Owner != "main" { - t.Fatalf("expected thread owner main, got %s", thread.Owner) - } - - msgs, err := manager.ThreadMessages(task.ThreadID, 10) - if err != nil { - t.Fatalf("thread messages failed: %v", err) - } - if len(msgs) < 2 { - t.Fatalf("expected task and reply messages, got %+v", msgs) - } - if msgs[0].FromAgent != "main" || msgs[0].ToAgent != "coder" { - t.Fatalf("unexpected initial message: %+v", msgs[0]) - } - last := msgs[len(msgs)-1] - if last.FromAgent != "coder" || last.ToAgent != "main" || last.Type != "result" { - t.Fatalf("unexpected reply message: %+v", last) - } -} - -func TestSubagentMailboxInboxIncludesControlMessages(t *testing.T) { - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - time.Sleep(150 * time.Millisecond) - return "ok", nil - }) - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "run checks", - AgentID: "tester", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - - time.Sleep(30 * time.Millisecond) - if !manager.SteerTask("subagent-1", "focus on regressions") { - t.Fatalf("expected steer to succeed") - } - inbox, err := manager.Inbox("tester", 10) - if err != nil { - t.Fatalf("inbox failed: %v", err) - } - if len(inbox) < 1 { - t.Fatalf("expected queued control message, got %+v", inbox) - } - foundControl := false - for _, msg := range inbox { - if msg.Type == "control" && strings.Contains(msg.Content, "regressions") { - foundControl = true - break - } - } - if !foundControl { - t.Fatalf("expected control message in inbox, got %+v", inbox) - } - _ = waitSubagentDone(t, manager, 4*time.Second) -} - -func TestSubagentMailboxReplyAndAckFlow(t *testing.T) { - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - time.Sleep(150 * time.Millisecond) - return "ok", nil - }) - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "review patch", - AgentID: "tester", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - time.Sleep(30 * time.Millisecond) - if !manager.SendTaskMessage("subagent-1", "please confirm scope") { - t.Fatalf("expected send to succeed") - } - - inbox, err := manager.Inbox("tester", 10) - if err != nil { - t.Fatalf("inbox failed: %v", err) - } - if len(inbox) == 0 { - t.Fatalf("expected inbox messages") - } - initial := inbox[0] - if !manager.ReplyToTask("subagent-1", initial.MessageID, "working on it") { - t.Fatalf("expected reply to succeed") - } - threadMsgs, err := manager.ThreadMessages(initial.ThreadID, 10) - if err != nil { - t.Fatalf("thread messages failed: %v", err) - } - foundReply := false - for _, msg := range threadMsgs { - if msg.Type == "reply" && msg.ReplyTo == initial.MessageID { - foundReply = true - break - } - } - if !foundReply { - t.Fatalf("expected reply message linked to %s, got %+v", initial.MessageID, threadMsgs) - } - if !manager.AckTaskMessage("subagent-1", initial.MessageID) { - t.Fatalf("expected ack to succeed") - } - updated, ok := manager.Message(initial.MessageID) - if !ok { - t.Fatalf("expected message lookup to succeed") - } - if updated.Status != "acked" { - t.Fatalf("expected acked status, got %+v", updated) - } - queuedInbox, err := manager.Inbox("tester", 10) - if err != nil { - t.Fatalf("queued inbox failed: %v", err) - } - for _, msg := range queuedInbox { - if msg.MessageID == initial.MessageID { - t.Fatalf("acked message should not remain in queued inbox: %+v", queuedInbox) - } - } - _ = waitSubagentDone(t, manager, 4*time.Second) -} - -func TestSubagentResumeConsumesQueuedThreadInbox(t *testing.T) { - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - observedQueued := make(chan int, 4) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - inbox, err := manager.TaskInbox(task.ID, 10) - if err != nil { - return "", err - } - observedQueued <- len(inbox) - return "ok", nil - }) - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "initial task", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - initial := waitSubagentDone(t, manager, 4*time.Second) - if queued := <-observedQueued; queued != 0 { - t.Fatalf("expected initial run to see empty queued inbox during execution, got %d", queued) - } - - if !manager.SendTaskMessage(initial.ID, "please address follow-up") { - t.Fatalf("expected send to succeed") - } - inbox, err := manager.Inbox("coder", 10) - if err != nil { - t.Fatalf("inbox failed: %v", err) - } - if len(inbox) == 0 { - t.Fatalf("expected queued inbox after send") - } - messageID := inbox[0].MessageID - - if _, ok := manager.ResumeTask(context.Background(), initial.ID); !ok { - t.Fatalf("expected resume to succeed") - } - _ = waitSubagentDone(t, manager, 4*time.Second) - if queued := <-observedQueued; queued != 0 { - t.Fatalf("expected resumed run to consume queued inbox before execution, got %d", queued) - } - remaining, err := manager.Inbox("coder", 10) - if err != nil { - t.Fatalf("remaining inbox failed: %v", err) - } - for _, msg := range remaining { - if msg.MessageID == messageID { - t.Fatalf("expected consumed message to leave queued inbox, got %+v", remaining) - } - } - stored, ok := manager.Message(messageID) - if !ok { - t.Fatalf("expected stored message lookup") - } - if stored.Status != "acked" { - t.Fatalf("expected consumed message to be acked, got %+v", stored) - } -} - -func waitSubagentDone(t *testing.T, manager *SubagentManager, timeout time.Duration) *SubagentTask { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - tasks := manager.ListTasks() - if len(tasks) > 0 { - task := tasks[0] - for _, candidate := range tasks[1:] { - if candidate.Created > task.Created || (candidate.Created == task.Created && candidate.ID > task.ID) { - task = candidate - } - } - manager.mu.RLock() - _, stillRunning := manager.cancelFuncs[task.ID] - manager.mu.RUnlock() - if task.Status != "running" && !stillRunning { - return task - } - } - time.Sleep(30 * time.Millisecond) - } - t.Fatalf("timeout waiting for subagent completion") - return nil -} - -type captureProvider struct { - messages []providers.Message -} - -func (p *captureProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) { - p.messages = append([]providers.Message(nil), messages...) - return &providers.LLMResponse{Content: "ok", FinishReason: "stop"}, nil -} - -func (p *captureProvider) GetDefaultModel() string { return "test-model" } - -func TestSubagentUsesConfiguredSystemPromptFile(t *testing.T) { - workspace := t.TempDir() - if err := os.MkdirAll(filepath.Join(workspace, "agents", "coder"), 0755); err != nil { - t.Fatalf("mkdir failed: %v", err) - } - if err := os.WriteFile(filepath.Join(workspace, "AGENTS.md"), []byte("workspace-policy"), 0644); err != nil { - t.Fatalf("write workspace AGENTS failed: %v", err) - } - if err := os.WriteFile(filepath.Join(workspace, "agents", "coder", "AGENT.md"), []byte("coder-policy-from-file"), 0644); err != nil { - t.Fatalf("write coder AGENT failed: %v", err) - } - provider := &captureProvider{} - manager := NewSubagentManager(provider, workspace, nil) - if _, err := manager.ProfileStore().Upsert(SubagentProfile{ - AgentID: "coder", - Status: "active", - SystemPromptFile: "agents/coder/AGENT.md", - }); err != nil { - t.Fatalf("profile upsert failed: %v", err) - } - - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ - Task: "implement feature", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - _ = waitSubagentDone(t, manager, 4*time.Second) - if len(provider.messages) == 0 { - t.Fatalf("expected provider to receive messages") - } - systemPrompt := provider.messages[0].Content - if !strings.Contains(systemPrompt, "coder-policy-from-file") { - t.Fatalf("expected system prompt to include configured file content, got: %s", systemPrompt) - } - if strings.Contains(systemPrompt, "inline-fallback") { - t.Fatalf("expected configured file content to take precedence over inline prompt, got: %s", systemPrompt) - } -} diff --git a/pkg/tools/subagent_store.go b/pkg/tools/subagent_store.go index f83dab6..6005e12 100644 --- a/pkg/tools/subagent_store.go +++ b/pkg/tools/subagent_store.go @@ -3,7 +3,6 @@ package tools import ( "bufio" "encoding/json" - "fmt" "os" "path/filepath" "sort" @@ -27,7 +26,7 @@ type SubagentRunStore struct { runsPath string eventsPath string mu sync.RWMutex - runs map[string]*SubagentTask + runs map[string]*SubagentRun } func NewSubagentRunStore(workspace string) *SubagentRunStore { @@ -40,7 +39,7 @@ func NewSubagentRunStore(workspace string) *SubagentRunStore { dir: dir, runsPath: filepath.Join(dir, "subagent_runs.jsonl"), eventsPath: filepath.Join(dir, "subagent_events.jsonl"), - runs: map[string]*SubagentTask{}, + runs: map[string]*SubagentRun{}, } _ = os.MkdirAll(dir, 0755) _ = store.load() @@ -51,7 +50,7 @@ func (s *SubagentRunStore) load() error { s.mu.Lock() defer s.mu.Unlock() - s.runs = map[string]*SubagentTask{} + s.runs = map[string]*SubagentRun{} f, err := os.Open(s.runsPath) if err != nil { if os.IsNotExist(err) { @@ -71,7 +70,7 @@ func (s *SubagentRunStore) load() error { } var record RunRecord if err := json.Unmarshal([]byte(line), &record); err == nil && strings.TrimSpace(record.ID) != "" { - task := &SubagentTask{ + run := &SubagentRun{ ID: record.ID, Task: record.Input, AgentID: record.AgentID, @@ -83,25 +82,25 @@ func (s *SubagentRunStore) load() error { Created: record.CreatedAt, Updated: record.UpdatedAt, } - s.runs[task.ID] = task + s.runs[run.ID] = run continue } - var task SubagentTask - if err := json.Unmarshal([]byte(line), &task); err != nil { + var run SubagentRun + if err := json.Unmarshal([]byte(line), &run); err != nil { continue } - cp := cloneSubagentTask(&task) - s.runs[task.ID] = cp + cp := cloneSubagentRun(&run) + s.runs[run.ID] = cp } return scanner.Err() } -func (s *SubagentRunStore) AppendRun(task *SubagentTask) error { - if s == nil || task == nil { +func (s *SubagentRunStore) AppendRun(run *SubagentRun) error { + if s == nil || run == nil { return nil } - cp := cloneSubagentTask(task) - data, err := json.Marshal(taskToRunRecord(cp)) + cp := cloneSubagentRun(run) + data, err := json.Marshal(runToRunRecord(cp)) if err != nil { return err } @@ -130,7 +129,7 @@ func (s *SubagentRunStore) AppendEvent(evt SubagentRunEvent) error { record := EventRecord{ ID: EventRecordID(evt.RunID, evt.Type, evt.At), RunID: evt.RunID, - TaskID: evt.RunID, + RequestID: evt.RunID, AgentID: evt.AgentID, Type: evt.Type, Status: evt.Status, @@ -156,28 +155,28 @@ func (s *SubagentRunStore) AppendEvent(evt SubagentRunEvent) error { return err } -func (s *SubagentRunStore) Get(runID string) (*SubagentTask, bool) { +func (s *SubagentRunStore) Get(runID string) (*SubagentRun, bool) { if s == nil { return nil, false } s.mu.RLock() defer s.mu.RUnlock() - task, ok := s.runs[strings.TrimSpace(runID)] + run, ok := s.runs[strings.TrimSpace(runID)] if !ok { return nil, false } - return cloneSubagentTask(task), true + return cloneSubagentRun(run), true } -func (s *SubagentRunStore) List() []*SubagentTask { +func (s *SubagentRunStore) List() []*SubagentRun { if s == nil { return nil } s.mu.RLock() defer s.mu.RUnlock() - out := make([]*SubagentTask, 0, len(s.runs)) - for _, task := range s.runs { - out = append(out, cloneSubagentTask(task)) + out := make([]*SubagentRun, 0, len(s.runs)) + for _, run := range s.runs { + out = append(out, cloneSubagentRun(run)) } sort.Slice(out, func(i, j int) bool { if out[i].Created != out[j].Created { @@ -269,85 +268,72 @@ func parseSubagentSequence(runID string) int { return n } -func cloneSubagentTask(task *SubagentTask) *SubagentTask { - if task == nil { +func cloneSubagentRun(run *SubagentRun) *SubagentRun { + if run == nil { return nil } - cp := *task - if len(task.ToolAllowlist) > 0 { - cp.ToolAllowlist = append([]string(nil), task.ToolAllowlist...) + cp := *run + if len(run.ToolAllowlist) > 0 { + cp.ToolAllowlist = append([]string(nil), run.ToolAllowlist...) } - if len(task.Steering) > 0 { - cp.Steering = append([]string(nil), task.Steering...) + if len(run.Steering) > 0 { + cp.Steering = append([]string(nil), run.Steering...) } - if task.SharedState != nil { - cp.SharedState = make(map[string]interface{}, len(task.SharedState)) - for k, v := range task.SharedState { + if run.SharedState != nil { + cp.SharedState = make(map[string]interface{}, len(run.SharedState)) + for k, v := range run.SharedState { cp.SharedState[k] = v } } return &cp } -func taskToTaskRecord(task *SubagentTask) TaskRecord { - if task == nil { - return TaskRecord{} +func runToRequestRecord(run *SubagentRun) RequestRecord { + if run == nil { + return RequestRecord{} } - return TaskRecord{ - ID: task.ID, - ThreadID: task.ThreadID, - CorrelationID: task.CorrelationID, - OwnerAgentID: task.AgentID, - Status: strings.TrimSpace(task.Status), - Input: task.Task, - OriginChannel: task.OriginChannel, - OriginChatID: task.OriginChatID, - CreatedAt: task.Created, - UpdatedAt: task.Updated, + return RequestRecord{ + ID: run.ID, + ThreadID: run.ThreadID, + CorrelationID: run.CorrelationID, + OwnerAgentID: run.AgentID, + Status: strings.TrimSpace(run.Status), + Input: run.Task, + OriginChannel: run.OriginChannel, + OriginChatID: run.OriginChatID, + CreatedAt: run.Created, + UpdatedAt: run.Updated, } } -func taskRuntimeError(task *SubagentTask) *RuntimeError { - if task == nil || !strings.EqualFold(strings.TrimSpace(task.Status), RuntimeStatusFailed) { +func runRuntimeError(run *SubagentRun) *RuntimeError { + if run == nil || !strings.EqualFold(strings.TrimSpace(run.Status), RuntimeStatusFailed) { return nil } - msg := strings.TrimSpace(task.Result) + msg := strings.TrimSpace(run.Result) msg = strings.TrimPrefix(msg, "Error:") msg = strings.TrimSpace(msg) return NewRuntimeError("subagent_failed", msg, "subagent", false, "subagent") } -func taskToRunRecord(task *SubagentTask) RunRecord { - if task == nil { +func runToRunRecord(run *SubagentRun) RunRecord { + if run == nil { return RunRecord{} } return RunRecord{ - ID: task.ID, - TaskID: task.ID, - ThreadID: task.ThreadID, - CorrelationID: task.CorrelationID, - AgentID: task.AgentID, - ParentRunID: task.ParentRunID, + ID: run.ID, + RequestID: run.ID, + ThreadID: run.ThreadID, + CorrelationID: run.CorrelationID, + AgentID: run.AgentID, + ParentRunID: run.ParentRunID, Kind: "subagent", - Status: strings.TrimSpace(task.Status), - Input: task.Task, - Output: strings.TrimSpace(task.Result), - Error: taskRuntimeError(task), - CreatedAt: task.Created, - UpdatedAt: task.Updated, + Status: strings.TrimSpace(run.Status), + Input: run.Task, + Output: strings.TrimSpace(run.Result), + Error: runRuntimeError(run), + CreatedAt: run.Created, + UpdatedAt: run.Updated, } } -func formatSubagentEventLog(evt SubagentRunEvent) string { - base := fmt.Sprintf("- %d %s", evt.At, evt.Type) - if strings.TrimSpace(evt.Status) != "" { - base += fmt.Sprintf(" status=%s", evt.Status) - } - if evt.RetryCount > 0 { - base += fmt.Sprintf(" retry=%d", evt.RetryCount) - } - if strings.TrimSpace(evt.Message) != "" { - base += fmt.Sprintf(" msg=%s", strings.TrimSpace(evt.Message)) - } - return base -} diff --git a/pkg/tools/subagents_tool.go b/pkg/tools/subagents_tool.go deleted file mode 100644 index 70fd29f..0000000 --- a/pkg/tools/subagents_tool.go +++ /dev/null @@ -1,325 +0,0 @@ -package tools - -import ( - "context" - "fmt" - "sort" - "strconv" - "strings" - "time" -) - -type SubagentsTool struct { - manager *SubagentManager -} - -func NewSubagentsTool(m *SubagentManager) *SubagentsTool { - return &SubagentsTool{manager: m} -} - -func (t *SubagentsTool) Name() string { return "subagents" } - -func (t *SubagentsTool) Description() string { - return "Manage subagent runs in current process: list, info, kill, steer" -} - -func (t *SubagentsTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{"type": "string", "description": "list|info|kill|steer|send|log|resume|thread|inbox|reply|trace|ack"}, - "id": map[string]interface{}{"type": "string", "description": "subagent id/#index/all for info/kill/steer/send/log"}, - "message": map[string]interface{}{"type": "string", "description": "steering message for steer/send action"}, - "message_id": map[string]interface{}{"type": "string", "description": "message id for reply/ack"}, - "thread_id": map[string]interface{}{"type": "string", "description": "thread id for thread/trace action; defaults to task thread"}, - "agent_id": map[string]interface{}{"type": "string", "description": "agent id for inbox action; defaults to task agent"}, - "limit": map[string]interface{}{"type": "integer", "description": "max messages/events to show", "default": 20}, - "recent_minutes": map[string]interface{}{"type": "integer", "description": "optional list/info all filter by recent updated minutes"}, - }, - "required": []string{"action"}, - } -} - -func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { - _ = ctx - if t.manager == nil { - return "subagent manager not available", nil - } - action := strings.ToLower(MapStringArg(args, "action")) - id := MapStringArg(args, "id") - message := MapStringArg(args, "message") - messageID := MapStringArg(args, "message_id") - threadID := MapStringArg(args, "thread_id") - agentID := MapStringArg(args, "agent_id") - limit := MapIntArg(args, "limit", 20) - recentMinutes := MapIntArg(args, "recent_minutes", 0) - - switch action { - case "list": - tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes) - if len(tasks) == 0 { - return "No subagents.", nil - } - var sb strings.Builder - sb.WriteString("Subagents:\n") - sort.Slice(tasks, func(i, j int) bool { return tasks[i].Created > tasks[j].Created }) - for i, task := range tasks { - sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s agent=%s role=%s session=%s allowlist=%d retry=%d timeout=%ds\n", - i+1, task.ID, task.Status, task.Label, task.AgentID, task.Role, task.SessionKey, len(task.ToolAllowlist), task.MaxRetries, task.TimeoutSec)) - } - return strings.TrimSpace(sb.String()), nil - case "info": - if strings.EqualFold(strings.TrimSpace(id), "all") { - tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes) - if len(tasks) == 0 { - return "No subagents.", nil - } - sort.Slice(tasks, func(i, j int) bool { return tasks[i].Created > tasks[j].Created }) - var sb strings.Builder - sb.WriteString("Subagents Summary:\n") - for i, task := range tasks { - sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s agent=%s role=%s steering=%d allowlist=%d retry=%d timeout=%ds\n", - i+1, task.ID, task.Status, task.Label, task.AgentID, task.Role, len(task.Steering), len(task.ToolAllowlist), task.MaxRetries, task.TimeoutSec)) - } - return strings.TrimSpace(sb.String()), nil - } - resolvedID, err := t.resolveTaskID(id) - if err != nil { - return err.Error(), nil - } - task, ok := t.manager.GetTask(resolvedID) - if !ok { - return "subagent not found", nil - } - info := fmt.Sprintf("ID: %s\nStatus: %s\nLabel: %s\nAgent ID: %s\nRole: %s\nSession Key: %s\nThread ID: %s\nCorrelation ID: %s\nWaiting Reply: %t\nMemory Namespace: %s\nTool Allowlist: %v\nMax Retries: %d\nRetry Count: %d\nRetry Backoff(ms): %d\nTimeout(s): %d\nMax Task Chars: %d\nMax Result Chars: %d\nCreated: %d\nUpdated: %d\nSteering Count: %d\nTask: %s\nResult:\n%s", - task.ID, task.Status, task.Label, task.AgentID, task.Role, task.SessionKey, task.ThreadID, task.CorrelationID, task.WaitingReply, task.MemoryNS, - task.ToolAllowlist, task.MaxRetries, task.RetryCount, task.RetryBackoff, task.TimeoutSec, task.MaxTaskChars, task.MaxResultChars, - task.Created, task.Updated, len(task.Steering), task.Task, task.Result) - if events, err := t.manager.Events(task.ID, 6); err == nil && len(events) > 0 { - var sb strings.Builder - sb.WriteString(info) - sb.WriteString("\nEvents:\n") - for _, evt := range events { - sb.WriteString(formatSubagentEventLog(evt) + "\n") - } - return strings.TrimSpace(sb.String()), nil - } - return info, nil - case "kill": - if strings.EqualFold(strings.TrimSpace(id), "all") { - tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes) - if len(tasks) == 0 { - return "No subagents.", nil - } - killed := 0 - for _, task := range tasks { - if t.manager.KillTask(task.ID) { - killed++ - } - } - return fmt.Sprintf("subagent kill requested for %d tasks", killed), nil - } - resolvedID, err := t.resolveTaskID(id) - if err != nil { - return err.Error(), nil - } - if !t.manager.KillTask(resolvedID) { - return "subagent not found", nil - } - return "subagent kill requested", nil - case "steer": - if message == "" { - return "message is required for steer", nil - } - resolvedID, err := t.resolveTaskID(id) - if err != nil { - return err.Error(), nil - } - if !t.manager.SteerTask(resolvedID, message) { - return "subagent not found", nil - } - return "steering message accepted", nil - case "send": - if message == "" { - return "message is required for send", nil - } - resolvedID, err := t.resolveTaskID(id) - if err != nil { - return err.Error(), nil - } - if !t.manager.SendTaskMessage(resolvedID, message) { - return "subagent not found", nil - } - return "message sent", nil - case "reply": - if message == "" { - return "message is required for reply", nil - } - resolvedID, err := t.resolveTaskID(id) - if err != nil { - return err.Error(), nil - } - if !t.manager.ReplyToTask(resolvedID, messageID, message) { - return "subagent not found", nil - } - return "reply sent", nil - case "ack": - if messageID == "" { - return "message_id is required for ack", nil - } - resolvedID, err := t.resolveTaskID(id) - if err != nil { - return err.Error(), nil - } - if !t.manager.AckTaskMessage(resolvedID, messageID) { - return "subagent or message not found", nil - } - return "message acked", nil - case "thread", "trace": - if threadID == "" { - resolvedID, err := t.resolveTaskID(id) - if err != nil { - return err.Error(), nil - } - task, ok := t.manager.GetTask(resolvedID) - if !ok { - return "subagent not found", nil - } - threadID = task.ThreadID - } - if threadID == "" { - return "thread_id is required", nil - } - thread, ok := t.manager.Thread(threadID) - if !ok { - return "thread not found", nil - } - msgs, err := t.manager.ThreadMessages(threadID, limit) - if err != nil { - return "", err - } - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Thread: %s\nOwner: %s\nStatus: %s\nParticipants: %s\nTopic: %s\n", - thread.ThreadID, thread.Owner, thread.Status, strings.Join(thread.Participants, ","), thread.Topic)) - if len(msgs) > 0 { - sb.WriteString("Messages:\n") - for _, msg := range msgs { - sb.WriteString(fmt.Sprintf("- %s %s -> %s type=%s reply_to=%s status=%s\n %s\n", - msg.MessageID, msg.FromAgent, msg.ToAgent, msg.Type, msg.ReplyTo, msg.Status, msg.Content)) - } - } - return strings.TrimSpace(sb.String()), nil - case "inbox": - if agentID == "" { - resolvedID, err := t.resolveTaskID(id) - if err != nil { - return err.Error(), nil - } - task, ok := t.manager.GetTask(resolvedID) - if !ok { - return "subagent not found", nil - } - agentID = task.AgentID - } - if agentID == "" { - return "agent_id is required", nil - } - msgs, err := t.manager.Inbox(agentID, limit) - if err != nil { - return "", err - } - if len(msgs) == 0 { - return "No inbox messages.", nil - } - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Inbox for %s:\n", agentID)) - for _, msg := range msgs { - sb.WriteString(fmt.Sprintf("- %s thread=%s from=%s type=%s status=%s\n %s\n", - msg.MessageID, msg.ThreadID, msg.FromAgent, msg.Type, msg.Status, msg.Content)) - } - return strings.TrimSpace(sb.String()), nil - case "log": - resolvedID, err := t.resolveTaskID(id) - if err != nil { - return err.Error(), nil - } - task, ok := t.manager.GetTask(resolvedID) - if !ok { - return "subagent not found", nil - } - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Subagent %s Log\n", task.ID)) - sb.WriteString(fmt.Sprintf("Status: %s\n", task.Status)) - sb.WriteString(fmt.Sprintf("Agent ID: %s\nRole: %s\nSession Key: %s\nThread ID: %s\nCorrelation ID: %s\nWaiting Reply: %t\nTool Allowlist: %v\nMax Retries: %d\nRetry Count: %d\nRetry Backoff(ms): %d\nTimeout(s): %d\n", - task.AgentID, task.Role, task.SessionKey, task.ThreadID, task.CorrelationID, task.WaitingReply, task.ToolAllowlist, task.MaxRetries, task.RetryCount, task.RetryBackoff, task.TimeoutSec)) - if len(task.Steering) > 0 { - sb.WriteString("Steering Messages:\n") - for _, m := range task.Steering { - sb.WriteString("- " + m + "\n") - } - } - if events, err := t.manager.Events(task.ID, 20); err == nil && len(events) > 0 { - sb.WriteString("Events:\n") - for _, evt := range events { - sb.WriteString(formatSubagentEventLog(evt) + "\n") - } - } - if strings.TrimSpace(task.Result) != "" { - result := strings.TrimSpace(task.Result) - if len(result) > 500 { - result = result[:500] + "..." - } - sb.WriteString("Result Preview:\n" + result) - } - return strings.TrimSpace(sb.String()), nil - case "resume": - resolvedID, err := t.resolveTaskID(id) - if err != nil { - return err.Error(), nil - } - label, ok := t.manager.ResumeTask(ctx, resolvedID) - if !ok { - return "subagent resume failed", nil - } - return fmt.Sprintf("subagent resumed as %s", label), nil - default: - return "unsupported action", nil - } -} - -func (t *SubagentsTool) resolveTaskID(idOrIndex string) (string, error) { - idOrIndex = strings.TrimSpace(idOrIndex) - if idOrIndex == "" { - return "", fmt.Errorf("id is required") - } - if strings.HasPrefix(idOrIndex, "#") { - n, err := strconv.Atoi(strings.TrimPrefix(idOrIndex, "#")) - if err != nil || n <= 0 { - return "", fmt.Errorf("invalid subagent index") - } - tasks := t.manager.ListTasks() - if len(tasks) == 0 { - return "", fmt.Errorf("no subagents") - } - sort.Slice(tasks, func(i, j int) bool { return tasks[i].Created > tasks[j].Created }) - if n > len(tasks) { - return "", fmt.Errorf("subagent index out of range") - } - return tasks[n-1].ID, nil - } - return idOrIndex, nil -} - -func (t *SubagentsTool) filterRecent(tasks []*SubagentTask, recentMinutes int) []*SubagentTask { - if recentMinutes <= 0 { - return tasks - } - cutoff := time.Now().Add(-time.Duration(recentMinutes) * time.Minute).UnixMilli() - out := make([]*SubagentTask, 0, len(tasks)) - for _, task := range tasks { - if task.Updated >= cutoff { - out = append(out, task) - } - } - return out -} diff --git a/pkg/tools/task_watchdog.go b/pkg/tools/task_watchdog.go deleted file mode 100644 index e422c01..0000000 --- a/pkg/tools/task_watchdog.go +++ /dev/null @@ -1,888 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" -) - -const ( - minCommandTick = 1 * time.Second - maxCommandTick = 45 * time.Second - watchdogTick = 1 * time.Second - minWorldCycle = 10 * time.Second - maxWorldCycle = 60 * time.Second -) - -const GlobalWatchdogTick = watchdogTick - -var ErrCommandNoProgress = errors.New("command no progress across tick rounds") -var ErrTaskWatchdogTimeout = errors.New("task watchdog timeout exceeded") - -type commandRuntimePolicy struct { - BaseTick time.Duration - StallRoundLimit int - MaxRestarts int - Difficulty int -} - -func buildCommandRuntimePolicy(command string, baseTick time.Duration) commandRuntimePolicy { - diff := commandDifficulty(command) - cpu := runtime.NumCPU() - - // Baseline: kill/restart after 5 unchanged-progress ticks. - stallLimit := 5 - // Difficulty adjustment (1..4) => +0..6 rounds. - stallLimit += (diff - 1) * 2 - // Hardware adjustment: weaker CPU gets more patience. - switch { - case cpu <= 4: - stallLimit += 5 - case cpu <= 8: - stallLimit += 3 - case cpu <= 16: - stallLimit += 1 - } - if stallLimit < 5 { - stallLimit = 5 - } - if stallLimit > 24 { - stallLimit = 24 - } - - // Restart budget: heavier tasks and weaker CPUs allow extra retries. - restarts := 1 - if diff >= 3 { - restarts++ - } - if cpu <= 4 { - restarts++ - } - if restarts > 3 { - restarts = 3 - } - - return commandRuntimePolicy{ - BaseTick: normalizeCommandTick(baseTick), - StallRoundLimit: stallLimit, - MaxRestarts: restarts, - Difficulty: diff, - } -} - -type commandWatchdog struct { - mu sync.Mutex - watches map[uint64]*watchedCommand - waiters []*watchWaiter - nextID uint64 - cpuTotal int - baseActive int - baseHeavy int - reservePct float64 - usageRatio float64 - lastSample time.Time - worldCycle time.Duration - nextSampleAt time.Time - active int - activeHeavy int - queueLimit int - queuePath string -} - -type watchedCommand struct { - id uint64 - cmd *exec.Cmd - startedAt time.Time - baseTick time.Duration - stallRoundLimit int - nextCheckAt time.Time - lastProgress int - stalledRounds int - progressFn func() int - stallNotify chan int - heavy bool - source string - label string -} - -type stalledCommand struct { - cmd *exec.Cmd - rounds int - notify chan int -} - -type watchWaiter struct { - id uint64 - heavy bool - ready chan struct{} - source string - label string - enqueuedAt time.Time -} - -var globalCommandWatchdog = newCommandWatchdog() -var reLoadAverage = regexp.MustCompile(`load averages?:\s*([0-9]+(?:[.,][0-9]+)?)`) - -func newCommandWatchdog() *commandWatchdog { - cpu := runtime.NumCPU() - baseActive, baseHeavy, queueLimit := deriveWatchdogLimits(cpu) - wd := &commandWatchdog{ - watches: make(map[uint64]*watchedCommand), - waiters: make([]*watchWaiter, 0, queueLimit), - cpuTotal: cpu, - baseActive: baseActive, - baseHeavy: baseHeavy, - reservePct: 0.20, - usageRatio: 0, - worldCycle: 20 * time.Second, - queueLimit: queueLimit, - } - go wd.loop() - return wd -} - -func deriveWatchdogLimits(cpu int) (maxActive, maxHeavy, queueLimit int) { - if cpu <= 0 { - cpu = 2 - } - maxActive = cpu - if maxActive < 2 { - maxActive = 2 - } - if maxActive > 12 { - maxActive = 12 - } - maxHeavy = cpu/4 + 1 - if maxHeavy < 1 { - maxHeavy = 1 - } - if maxHeavy > 4 { - maxHeavy = 4 - } - queueLimit = maxActive * 8 - if queueLimit < 16 { - queueLimit = 16 - } - return -} - -func (wd *commandWatchdog) loop() { - ticker := time.NewTicker(watchdogTick) - defer ticker.Stop() - for now := range ticker.C { - wd.refreshSystemUsage(now) - wd.tick(now) - } -} - -func (wd *commandWatchdog) refreshSystemUsage(now time.Time) { - if wd == nil { - return - } - wd.mu.Lock() - if wd.nextSampleAt.IsZero() { - wd.nextSampleAt = now - } - if now.Before(wd.nextSampleAt) { - wd.mu.Unlock() - return - } - wd.lastSample = now - cpu := wd.cpuTotal - cycle := wd.computeWorldCycleLocked() - wd.worldCycle = cycle - wd.nextSampleAt = now.Add(cycle) - wd.mu.Unlock() - - usage := sampleSystemUsageRatio(cpu) - - wd.mu.Lock() - wd.usageRatio = usage - wd.mu.Unlock() -} - -func (wd *commandWatchdog) computeWorldCycleLocked() time.Duration { - if wd == nil { - return 20 * time.Second - } - // Game-world style cycle: - // base=20s; busier world => shorter cycle; idle world => longer cycle. - cycle := 20 * time.Second - pending := len(wd.waiters) - if pending > 0 { - cycle -= time.Duration(minInt(pending, 8)) * time.Second - } - if wd.active > wd.baseActive/2 { - cycle -= 3 * time.Second - } - if wd.active == 0 && pending == 0 { - cycle += 10 * time.Second - } - if cycle < minWorldCycle { - cycle = minWorldCycle - } - if cycle > maxWorldCycle { - cycle = maxWorldCycle - } - return cycle -} - -func (wd *commandWatchdog) register(cmd *exec.Cmd, baseTick time.Duration, stallRoundLimit int, progressFn func() int, stallNotify chan int, heavy bool, source, label string) func() { - if wd == nil || cmd == nil { - return func() {} - } - base := normalizeCommandTick(baseTick) - id := atomic.AddUint64(&wd.nextID, 1) - w := &watchedCommand{ - id: id, - cmd: cmd, - startedAt: time.Now(), - baseTick: base, - stallRoundLimit: stallRoundLimit, - nextCheckAt: time.Now().Add(base), - lastProgress: safeProgress(progressFn), - progressFn: progressFn, - stallNotify: stallNotify, - heavy: heavy, - source: strings.TrimSpace(source), - label: strings.TrimSpace(label), - } - - wd.mu.Lock() - wd.watches[id] = w - snap := wd.buildQueueSnapshotLocked() - wd.mu.Unlock() - wd.writeQueueSnapshot(snap) - - var once sync.Once - return func() { - once.Do(func() { - wd.mu.Lock() - delete(wd.watches, id) - snap := wd.buildQueueSnapshotLocked() - wd.mu.Unlock() - wd.writeQueueSnapshot(snap) - }) - } -} - -func (wd *commandWatchdog) setQueuePath(path string) { - if wd == nil { - return - } - path = strings.TrimSpace(path) - if path != "" { - path = filepath.Clean(path) - } - wd.mu.Lock() - changed := wd.queuePath != path - wd.queuePath = path - snap := wd.buildQueueSnapshotLocked() - wd.mu.Unlock() - if changed { - wd.writeQueueSnapshot(snap) - } -} - -func (wd *commandWatchdog) acquireSlot(ctx context.Context, heavy bool, source, label string) (func(), error) { - if wd == nil { - return func() {}, nil - } - if ctx == nil { - ctx = context.Background() - } - wd.mu.Lock() - if wd.canAcquireSlotLocked(heavy) { - wd.grantSlotLocked(heavy) - snap := wd.buildQueueSnapshotLocked() - wd.mu.Unlock() - wd.writeQueueSnapshot(snap) - return wd.releaseSlotFunc(heavy), nil - } - // Queue when slots are full; wait until a slot is available or context cancels. - waitID := atomic.AddUint64(&wd.nextID, 1) - w := &watchWaiter{ - id: waitID, - heavy: heavy, - ready: make(chan struct{}, 1), - source: strings.TrimSpace(source), - label: strings.TrimSpace(label), - enqueuedAt: time.Now(), - } - wd.waiters = append(wd.waiters, w) - snap := wd.buildQueueSnapshotLocked() - wd.mu.Unlock() - wd.writeQueueSnapshot(snap) - - for { - select { - case <-ctx.Done(): - wd.mu.Lock() - wd.removeWaiterLocked(waitID) - snap := wd.buildQueueSnapshotLocked() - wd.mu.Unlock() - wd.writeQueueSnapshot(snap) - return nil, ctx.Err() - case <-w.ready: - return wd.releaseSlotFunc(heavy), nil - } - } -} - -func (wd *commandWatchdog) releaseSlotFunc(heavy bool) func() { - var once sync.Once - return func() { - once.Do(func() { - wd.mu.Lock() - if wd.active > 0 { - wd.active-- - } - if heavy && wd.activeHeavy > 0 { - wd.activeHeavy-- - } - wd.scheduleWaitersLocked() - snap := wd.buildQueueSnapshotLocked() - wd.mu.Unlock() - wd.writeQueueSnapshot(snap) - }) - } -} - -func (wd *commandWatchdog) canAcquireSlotLocked(heavy bool) bool { - maxActive, maxHeavy := wd.dynamicLimitsLocked() - if wd.active >= maxActive { - return false - } - if heavy && wd.activeHeavy >= maxHeavy { - return false - } - return true -} - -func (wd *commandWatchdog) grantSlotLocked(heavy bool) { - wd.active++ - if heavy { - wd.activeHeavy++ - } -} - -func (wd *commandWatchdog) dynamicLimitsLocked() (maxActive, maxHeavy int) { - if wd == nil { - return 1, 1 - } - maxActive = computeDynamicActiveSlots(wd.cpuTotal, wd.reservePct, wd.usageRatio, wd.baseActive) - maxHeavy = computeDynamicHeavySlots(maxActive, wd.baseHeavy) - return -} - -func computeDynamicActiveSlots(cpu int, reservePct, usageRatio float64, baseActive int) int { - if cpu <= 0 { - cpu = 1 - } - if reservePct <= 0 { - reservePct = 0.20 - } - if reservePct > 0.90 { - reservePct = 0.90 - } - if usageRatio < 0 { - usageRatio = 0 - } - if usageRatio > 0.95 { - usageRatio = 0.95 - } - headroom := 1.0 - reservePct - usageRatio - if headroom < 0 { - headroom = 0 - } - maxActive := int(float64(cpu) * headroom) - if maxActive < 1 { - maxActive = 1 - } - if baseActive > 0 && maxActive > baseActive { - maxActive = baseActive - } - return maxActive -} - -func computeDynamicHeavySlots(maxActive, baseHeavy int) int { - if maxActive <= 0 { - return 1 - } - maxHeavy := maxActive/2 + 1 - if maxHeavy < 1 { - maxHeavy = 1 - } - if baseHeavy > 0 && maxHeavy > baseHeavy { - maxHeavy = baseHeavy - } - if maxHeavy > maxActive { - maxHeavy = maxActive - } - return maxHeavy -} - -func (wd *commandWatchdog) scheduleWaitersLocked() { - if len(wd.waiters) == 0 { - return - } - for { - progress := false - for i := 0; i < len(wd.waiters); { - w := wd.waiters[i] - if w == nil { - wd.waiters = append(wd.waiters[:i], wd.waiters[i+1:]...) - progress = true - continue - } - if !wd.canAcquireSlotLocked(w.heavy) { - i++ - continue - } - wd.grantSlotLocked(w.heavy) - wd.waiters = append(wd.waiters[:i], wd.waiters[i+1:]...) - select { - case w.ready <- struct{}{}: - default: - } - progress = true - } - if !progress { - break - } - } -} - -func (wd *commandWatchdog) removeWaiterLocked(id uint64) { - if id == 0 || len(wd.waiters) == 0 { - return - } - for i, w := range wd.waiters { - if w == nil || w.id != id { - continue - } - wd.waiters = append(wd.waiters[:i], wd.waiters[i+1:]...) - return - } -} - -func (wd *commandWatchdog) tick(now time.Time) { - if wd == nil { - return - } - toStall := make([]stalledCommand, 0, 4) - changed := false - - wd.mu.Lock() - for id, w := range wd.watches { - if w == nil { - delete(wd.watches, id) - changed = true - continue - } - if now.Before(w.nextCheckAt) { - continue - } - cur := safeProgress(w.progressFn) - if cur > w.lastProgress { - w.lastProgress = cur - w.stalledRounds = 0 - } else { - w.stalledRounds++ - changed = true - if w.stallRoundLimit > 0 && w.stalledRounds >= w.stallRoundLimit { - delete(wd.watches, id) - changed = true - toStall = append(toStall, stalledCommand{ - cmd: w.cmd, - rounds: w.stalledRounds, - notify: w.stallNotify, - }) - continue - } - } - next := nextCommandTick(w.baseTick, now.Sub(w.startedAt)) - w.nextCheckAt = now.Add(next) - changed = true - } - snap := wd.buildQueueSnapshotLocked() - wd.mu.Unlock() - - if changed { - wd.writeQueueSnapshot(snap) - } - - for _, st := range toStall { - if st.cmd != nil && st.cmd.Process != nil { - _ = st.cmd.Process.Kill() - } - if st.notify != nil { - select { - case st.notify <- st.rounds: - default: - } - } - } -} - -func safeProgress(progressFn func() int) (progress int) { - if progressFn == nil { - return 0 - } - defer func() { - if recover() != nil { - progress = 0 - } - }() - progress = progressFn() - if progress < 0 { - return 0 - } - return progress -} - -func runCommandWithDynamicTick(ctx context.Context, cmd *exec.Cmd, source, label string, difficulty int, baseTick time.Duration, stallRoundLimit int, progressFn func() int) error { - base := normalizeCommandTick(baseTick) - heavy := difficulty >= 3 - releaseSlot, err := globalCommandWatchdog.acquireSlot(ctx, heavy, source, label) - if err != nil { - return err - } - defer releaseSlot() - if err := cmd.Start(); err != nil { - return err - } - - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - stallNotify := make(chan int, 1) - unwatch := globalCommandWatchdog.register(cmd, base, stallRoundLimit, progressFn, stallNotify, heavy, source, label) - defer unwatch() - - for { - select { - case err := <-done: - return err - case stalledRounds := <-stallNotify: - select { - case err := <-done: - return fmt.Errorf("%w: %d ticks without progress (%v)", ErrCommandNoProgress, stalledRounds, err) - case <-time.After(2 * time.Second): - return fmt.Errorf("%w: %d ticks without progress", ErrCommandNoProgress, stalledRounds) - } - case <-ctx.Done(): - if cmd.Process != nil { - _ = cmd.Process.Kill() - } - select { - case err := <-done: - if err != nil { - return err - } - case <-time.After(2 * time.Second): - } - return ctx.Err() - } - } -} - -type stringTaskResult struct { - output string - err error -} - -type stringTaskWatchdogOptions struct { - ProgressFn func() int - CanExtend func() bool -} - -// runStringTaskWithTaskWatchdog executes a string-returning task with the same -// tick pacing as the command watchdog, but only times out after a full timeout -// window without observable progress or an allowed extension signal. -func runStringTaskWithTaskWatchdog( - ctx context.Context, - timeoutSec int, - baseTick time.Duration, - opts stringTaskWatchdogOptions, - run func(context.Context) (string, error), -) (string, error) { - if run == nil { - return "", fmt.Errorf("run function is nil") - } - if timeoutSec <= 0 { - return run(ctx) - } - if ctx == nil { - ctx = context.Background() - } - - timeout := time.Duration(timeoutSec) * time.Second - lastProgressAt := time.Now() - lastProgress := safeProgress(opts.ProgressFn) - tick := normalizeCommandTick(baseTick) - if tick <= 0 { - tick = 2 * time.Second - } - - runCtx, cancel := context.WithCancel(ctx) - defer cancel() - - done := make(chan stringTaskResult, 1) - go func() { - out, err := run(runCtx) - done <- stringTaskResult{output: out, err: err} - }() - - timer := time.NewTimer(tick) - defer timer.Stop() - - for { - select { - case <-ctx.Done(): - cancel() - return "", ctx.Err() - case res := <-done: - return res.output, res.err - case <-timer.C: - if cur := safeProgress(opts.ProgressFn); cur > lastProgress { - lastProgress = cur - lastProgressAt = time.Now() - } - stalledFor := time.Since(lastProgressAt) - if stalledFor >= timeout { - if opts.CanExtend != nil && opts.CanExtend() { - lastProgressAt = time.Now() - stalledFor = 0 - } else { - cancel() - select { - case res := <-done: - if res.err != nil { - return "", fmt.Errorf("%w: %v", ErrTaskWatchdogTimeout, res.err) - } - case <-time.After(2 * time.Second): - } - return "", fmt.Errorf("%w: %ds", ErrTaskWatchdogTimeout, timeoutSec) - } - } - next := nextCommandTick(tick, stalledFor) - if next <= 0 { - next = tick - } - timer.Reset(next) - } - } -} - -func (wd *commandWatchdog) buildQueueSnapshotLocked() map[string]interface{} { - if wd == nil { - return nil - } - maxActive, maxHeavy := wd.dynamicLimitsLocked() - running := make([]map[string]interface{}, 0, len(wd.watches)) - for _, w := range wd.watches { - if w == nil { - continue - } - running = append(running, map[string]interface{}{ - "id": w.id, - "source": queueNonEmpty(w.source, "exec"), - "label": w.label, - "heavy": w.heavy, - "status": "running", - "started_at": w.startedAt.UTC().Format(time.RFC3339), - "next_check_at": w.nextCheckAt.UTC().Format(time.RFC3339), - "stalled_rounds": w.stalledRounds, - "stall_round_limit": w.stallRoundLimit, - "last_progress": w.lastProgress, - }) - } - waiting := make([]map[string]interface{}, 0, len(wd.waiters)) - for _, w := range wd.waiters { - if w == nil { - continue - } - waiting = append(waiting, map[string]interface{}{ - "id": w.id, - "source": queueNonEmpty(w.source, "exec"), - "label": w.label, - "heavy": w.heavy, - "status": "waiting", - "enqueued_at": w.enqueuedAt.UTC().Format(time.RFC3339), - }) - } - return map[string]interface{}{ - "time": time.Now().UTC().Format(time.RFC3339), - "watchdog": map[string]interface{}{ - "cpu_total": wd.cpuTotal, - "reserve_pct": wd.reservePct, - "usage_ratio": wd.usageRatio, - "world_cycle_sec": int(wd.worldCycle.Seconds()), - "next_sample_at": func() string { - if wd.nextSampleAt.IsZero() { - return "" - } - return wd.nextSampleAt.UTC().Format(time.RFC3339) - }(), - "max_active": maxActive, - "max_heavy": maxHeavy, - "active": wd.active, - "active_heavy": wd.activeHeavy, - "waiting": len(waiting), - "running": len(running), - }, - "running": running, - "waiting": waiting, - } -} - -func (wd *commandWatchdog) writeQueueSnapshot(snap map[string]interface{}) { - if wd == nil || snap == nil { - return - } - wd.mu.Lock() - path := strings.TrimSpace(wd.queuePath) - wd.mu.Unlock() - if path == "" { - return - } - raw, err := json.MarshalIndent(snap, "", " ") - if err != nil { - return - } - _ = os.MkdirAll(filepath.Dir(path), 0755) - _ = os.WriteFile(path, raw, 0644) -} - -func queueNonEmpty(v, fallback string) string { - v = strings.TrimSpace(v) - if v == "" { - return fallback - } - return v -} - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} - -func nextCommandTick(baseTick, elapsed time.Duration) time.Duration { - base := normalizeCommandTick(baseTick) - if elapsed < 0 { - elapsed = 0 - } - next := base + elapsed/8 - if next > maxCommandTick { - return maxCommandTick - } - if next < base { - return base - } - return next -} - -func normalizeCommandTick(baseTick time.Duration) time.Duration { - if baseTick < minCommandTick { - return minCommandTick - } - if baseTick > maxCommandTick { - return maxCommandTick - } - return baseTick -} - -func commandDifficulty(command string) int { - cmd := strings.ToLower(strings.TrimSpace(command)) - if cmd == "" { - return 1 - } - // 4: very heavy build / container graph. - for _, p := range []string{"docker build", "docker compose build", "bazel build", "gradle build", "mvn package"} { - if strings.Contains(cmd, p) { - return 4 - } - } - // 3: compile/test/install heavy workloads. - for _, p := range []string{"go test", "go build", "cargo build", "npm install", "npm ci", "pnpm install", "yarn install", "npm run build", "pnpm build", "yarn build"} { - if strings.Contains(cmd, p) { - return 3 - } - } - // 2: medium multi-step shell chains. - if strings.Contains(cmd, "&&") || strings.Contains(cmd, "|") { - return 2 - } - return 1 -} - -func sampleSystemUsageRatio(cpu int) float64 { - if cpu <= 0 { - cpu = 1 - } - load1, ok := readLoadAverage1() - if !ok { - return 0 - } - ratio := load1 / float64(cpu) - if ratio < 0 { - return 0 - } - if ratio > 0.95 { - return 0.95 - } - return ratio -} - -func readLoadAverage1() (float64, bool) { - // Linux fast path. - if b, err := os.ReadFile("/proc/loadavg"); err == nil { - fields := strings.Fields(strings.TrimSpace(string(b))) - if len(fields) > 0 { - if v, err := strconv.ParseFloat(fields[0], 64); err == nil && v >= 0 { - return v, true - } - } - } - - // macOS/BSD fallback. - if out, err := runCommandOutputWithTimeout(300*time.Millisecond, "sysctl", "-n", "vm.loadavg"); err == nil { - fields := strings.Fields(strings.Trim(strings.TrimSpace(string(out)), "{}")) - if len(fields) > 0 { - if v, err := strconv.ParseFloat(strings.ReplaceAll(fields[0], ",", "."), 64); err == nil && v >= 0 { - return v, true - } - } - } - if out, err := runCommandOutputWithTimeout(300*time.Millisecond, "uptime"); err == nil { - m := reLoadAverage.FindStringSubmatch(strings.ToLower(string(out))) - if len(m) >= 2 { - if v, err := strconv.ParseFloat(strings.ReplaceAll(m[1], ",", "."), 64); err == nil && v >= 0 { - return v, true - } - } - } - return 0, false -} - -func runCommandOutputWithTimeout(timeout time.Duration, name string, args ...string) ([]byte, error) { - if timeout <= 0 { - timeout = 300 * time.Millisecond - } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - return exec.CommandContext(ctx, name, args...).Output() -} diff --git a/pkg/tools/task_watchdog_test.go b/pkg/tools/task_watchdog_test.go deleted file mode 100644 index 3ac6083..0000000 --- a/pkg/tools/task_watchdog_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package tools - -import ( - "context" - "errors" - "sync/atomic" - "testing" - "time" -) - -func TestRunStringTaskWithTaskWatchdogTimesOutWithoutExtension(t *testing.T) { - t.Parallel() - - started := time.Now() - _, err := runStringTaskWithTaskWatchdog( - context.Background(), - 1, - 100*time.Millisecond, - stringTaskWatchdogOptions{}, - func(ctx context.Context) (string, error) { - <-ctx.Done() - return "", ctx.Err() - }, - ) - if !errors.Is(err, ErrTaskWatchdogTimeout) { - t.Fatalf("expected ErrTaskWatchdogTimeout, got %v", err) - } - if elapsed := time.Since(started); elapsed > 3*time.Second { - t.Fatalf("expected watchdog timeout quickly, took %v", elapsed) - } -} - -func TestRunStringTaskWithTaskWatchdogAutoExtendsWhileRunning(t *testing.T) { - t.Parallel() - - started := time.Now() - out, err := runStringTaskWithTaskWatchdog( - context.Background(), - 1, - 100*time.Millisecond, - stringTaskWatchdogOptions{ - CanExtend: func() bool { return true }, - }, - func(ctx context.Context) (string, error) { - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-time.After(1500 * time.Millisecond): - return "ok", nil - } - }, - ) - if err != nil { - t.Fatalf("expected auto-extended task to finish, got %v", err) - } - if out != "ok" { - t.Fatalf("expected output ok, got %q", out) - } - if elapsed := time.Since(started); elapsed < time.Second { - t.Fatalf("expected task to run past initial timeout window, took %v", elapsed) - } -} - -func TestRunStringTaskWithTaskWatchdogExtendsOnProgress(t *testing.T) { - t.Parallel() - - var progress atomic.Int64 - done := make(chan struct{}) - go func() { - ticker := time.NewTicker(400 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-done: - return - case <-ticker.C: - progress.Add(1) - } - } - }() - defer close(done) - - out, err := runStringTaskWithTaskWatchdog( - context.Background(), - 1, - 100*time.Millisecond, - stringTaskWatchdogOptions{ - ProgressFn: func() int { return int(progress.Load()) }, - }, - func(ctx context.Context) (string, error) { - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-time.After(1500 * time.Millisecond): - return "done", nil - } - }, - ) - if err != nil { - t.Fatalf("expected progress-based extension to finish, got %v", err) - } - if out != "done" { - t.Fatalf("expected output done, got %q", out) - } -} diff --git a/pkg/tools/tool_allowlist_groups.go b/pkg/tools/tool_allowlist_groups.go index deee8e7..a301c2b 100644 --- a/pkg/tools/tool_allowlist_groups.go +++ b/pkg/tools/tool_allowlist_groups.go @@ -17,13 +17,13 @@ var defaultToolAllowlistGroups = []ToolAllowlistGroup{ Name: "files_read", Description: "Read-only workspace file tools", Aliases: []string{"file_read", "readonly_files"}, - Tools: []string{"read_file", "list_dir", "repo_map", "read"}, + Tools: []string{"read_file", "list_dir"}, }, { Name: "files_write", Description: "Workspace file modification tools", Aliases: []string{"file_write"}, - Tools: []string{"write_file", "edit_file", "write", "edit"}, + Tools: []string{"write_file", "edit_file"}, }, { Name: "memory_read", @@ -44,10 +44,10 @@ var defaultToolAllowlistGroups = []ToolAllowlistGroup{ Tools: []string{"memory_search", "memory_get", "memory_write"}, }, { - Name: "subagents", - Description: "Subagent management tools", - Aliases: []string{"subagent", "agent_runtime"}, - Tools: []string{"spawn", "subagents", "subagent_profile"}, + Name: "subagent", + Description: "Subagent execution tools", + Aliases: []string{"agent_runtime"}, + Tools: []string{"spawn", "subagent_profile"}, }, { Name: "skills", diff --git a/pkg/tools/tool_allowlist_groups_test.go b/pkg/tools/tool_allowlist_groups_test.go index 02dabcb..e7f1ab3 100644 --- a/pkg/tools/tool_allowlist_groups_test.go +++ b/pkg/tools/tool_allowlist_groups_test.go @@ -17,7 +17,7 @@ func TestExpandToolAllowlistEntries_GroupPrefix(t *testing.T) { } func TestExpandToolAllowlistEntries_BareGroupAndAlias(t *testing.T) { - got := ExpandToolAllowlistEntries([]string{"memory_all", "@subagents", "skill"}) + got := ExpandToolAllowlistEntries([]string{"memory_all", "@subagent", "skill"}) contains := map[string]bool{} for _, item := range got { contains[item] = true @@ -25,8 +25,8 @@ func TestExpandToolAllowlistEntries_BareGroupAndAlias(t *testing.T) { if !contains["memory_search"] || !contains["memory_write"] { t.Fatalf("memory_all expansion missing memory tools: %v", got) } - if !contains["spawn"] || !contains["subagents"] || !contains["subagent_profile"] { - t.Fatalf("subagents alias expansion missing subagent tools: %v", got) + if !contains["spawn"] || !contains["subagent_profile"] { + t.Fatalf("subagent group expansion missing subagent tools: %v", got) } if !contains["skill_exec"] { t.Fatalf("skills alias expansion missing skill_exec: %v", got) diff --git a/scripts/build-slim.ps1 b/scripts/build-slim.ps1 index 3bdd0e7..502f1f2 100644 --- a/scripts/build-slim.ps1 +++ b/scripts/build-slim.ps1 @@ -1,7 +1,6 @@ [CmdletBinding()] param( [string]$Output = "build/clawgo-windows-amd64-slim.exe", - [switch]$EmbedWebUI, [switch]$Compress ) @@ -10,7 +9,6 @@ $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot $embedDir = Join-Path $repoRoot "cmd/workspace" $workspaceDir = Join-Path $repoRoot "workspace" -$webuiDistDir = Join-Path $repoRoot "webui/dist" $outputPath = Join-Path $repoRoot $Output function Copy-DirectoryContents { @@ -33,14 +31,6 @@ try { Copy-DirectoryContents -Source $workspaceDir -Destination $embedDir - if ($EmbedWebUI) { - if (-not (Test-Path $webuiDistDir)) { - throw "EmbedWebUI was requested, but WebUI dist is missing: $webuiDistDir" - } - $embedWebuiDir = Join-Path $embedDir "webui" - Copy-DirectoryContents -Source $webuiDistDir -Destination $embedWebuiDir - } - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $outputPath) | Out-Null $env:CGO_ENABLED = "0"