diff --git a/README.md b/README.md index 12c270c..2a168e2 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,108 @@ -# ClawGo 🦞 +# ClawGo -**é¢å‘生产的 Go 原生 Agent Runtime。** +**一个é¢å‘长期è¿è¡Œçš„æ¸¸æˆä¸–界核心。** -ClawGo 䏿˜¯â€œåˆä¸€ä¸ªèŠå¤©å£³å­â€ï¼Œè€Œæ˜¯ä¸€å¥—å¯é•¿æœŸè¿è¡Œã€å¯è§‚测ã€å¯æ¢å¤ã€å¯ç¼–排的 Agent è¿è¡Œæ—¶ã€‚ +ClawGo 现在的核心定ä½ï¼Œä¸å†æ˜¯â€œé€šç”¨å¤š Agent 壳å­â€ï¼Œè€Œæ˜¯ä¸€ä¸ªç”± `main` 充当世界æ„å¿—ã€ç”± `npc` 充当自治角色ã€ç”±ç»“构化状æ€é©±åŠ¨çš„ **World Runtime**。 -- 👀 **å¯è§‚测**:Agent 拓扑ã€å†…部æµã€ä»»åŠ¡å®¡è®¡ã€EKG 一体化å¯è§ -- 🔠**坿¢å¤**:è¿è¡Œæ€è½ç›˜ï¼Œé‡å¯åŽä»»åС坿¢å¤ï¼Œwatchdog 按进展续时 -- 🧩 **å¯ç¼–排**:`main agent -> subagent -> main`ï¼Œæ”¯æŒæœ¬åœ°ä¸Žè¿œç«¯ node 分支 -- âš™ï¸ **å¯å·¥ç¨‹åŒ–**:`config.json`ã€`AGENT.md`ã€çƒ­æ›´æ–°ã€WebUIã€å£°æ˜Žå¼ registry +- **世界æ„å¿—**:`main` ç»´æŠ¤ä¸–ç•Œè§„åˆ™ã€æ—¶é—´æŽ¨è¿›ã€äº‹ä»¶è£å†³å’Œç»“果渲染 +- **自治 NPC**:NPC 有 personaã€ç›®æ ‡ã€è®°å¿†ã€å…³ç³»å’Œå±€éƒ¨æ„ŸçŸ¥ +- **结构化世界**:世界状æ€ã€NPC 状æ€ã€ä¸–界事件独立æŒä¹…化 +- **坿¢å¤è¿è¡Œ**:world/runtime è½ç›˜ï¼Œé‡å¯åŽå¯ç»§ç»­æŽ¨è¿› +- **å¯è§‚测**:WebUI å’Œ API å¯ç›´æŽ¥æŸ¥çœ‹åœ°ç‚¹ã€NPCã€å®žä½“ã€ä»»åŠ¡ã€äº‹ä»¶æµ +- **坿‰©å±•**:ä¿ç•™ `providers`ã€`channels`ã€node branch 等底层能力 [English](./README_EN.md) -## 为什么是 ClawGo +## 现在它是什么 -大多数 Agent 项目åœç•™åœ¨ï¼š +ClawGo 的默认è¿è¡Œæ¨¡åž‹æ˜¯ï¼š -- 一个èŠå¤©ç•Œé¢ -- 一组工具调用 -- 一段 prompt +```text +user -> main(world mind) -> npc/agent intents -> arbitrate -> apply -> render -> user +``` -ClawGo 更关注真正的è¿è¡Œæ—¶èƒ½åŠ›ï¼š +其中: -- `main agent` 负责入å£ã€è·¯ç”±ã€æ´¾å‘ã€æ±‡æ€» -- `subagent` 负责编ç ã€æµ‹è¯•ã€äº§å“ã€æ–‡æ¡£ç­‰å…·ä½“执行 -- `node branch` 把远端节点挂æˆå—控 agent 分支 -- `runtime store` æŒä¹…化 runã€eventã€threadã€messageã€memory +- `main` + - ç”¨æˆ·å…¥å£ + - 世界事件摄入 + - NPC 唤醒与调度 + - æ„图è£å†³ + - ä¸–ç•ŒçŠ¶æ€æ›´æ–° + - å™äº‹è¾“出 +- `npc` + - åªæå‡º `ActionIntent` + - ä¸ç›´æŽ¥æ”¹ä¸–界 + - 基于 personaã€goalsã€memoryã€visible events 自主行动 +- `world store` + - ä¿å­˜ä¸–界状æ€ã€NPC 状æ€ã€äº‹ä»¶å®¡è®¡ã€è¿è¡Œæ€è®°å½• 一å¥è¯ï¼š -> **ClawGo = Agent Runtime,而ä¸åªæ˜¯ Agent Chat。** +> **ClawGo = 游æˆä¸–界è¿è¡Œæ—¶ï¼Œè€Œä¸åªæ˜¯èŠå¤©å¼ Agent。** -## 核心亮点 ✨ +## 核心亮点 -### 1. 多 Agent 拓扑å¯è§†åŒ– +### 1. main 就是世界æ„å¿— -- 统一展示 `main / subagents / remote branches` -- 内部æµä¸Žç”¨æˆ·ä¸»å¯¹è¯åˆ†ç¦» -- å­ agent å作过程å¯è§‚æµ‹ï¼Œä½†ä¸æ±¡æŸ“ç”¨æˆ·é€šé“ +- `main` ä¸åªæ˜¯æ€»æŽ§ï¼Œè€Œæ˜¯ä¸–界è£å†³å†…æ ¸ +- 所有用户输入都会先转æˆä¸–界事件 +- 世界真正å‘生什么,由 `main` 统一è£å®š -### 2. ä»»åŠ¡å¯æ¢å¤ï¼Œä¸æ˜¯ä¸€æŒ‚全没 +### 2. NPC æ˜¯è‡ªæ²»è§’è‰²ï¼Œä¸æ˜¯è„šæœ¬æœ¨å¶ -- `subagent_runs.jsonl` -- `subagent_events.jsonl` -- `threads.jsonl` +- æ¯ä¸ª NPC 有独立 profile +- æ”¯æŒ personaã€traitsã€factionã€home locationã€default goals +- 支æŒé•¿æœŸç›®æ ‡é©±åŠ¨å’Œäº‹ä»¶å“应 +- 支æŒå§”æ‰˜ã€æ¶ˆæ¯ã€å±€éƒ¨æ„ŸçŸ¥ + +### 3. ä¸–ç•ŒçŠ¶æ€æ˜¯ç»“构化的 + +核心æŒä¹…化文件ä½äºŽ `workspace/agents/runtime`: + +- `world_state.json` +- `npc_state.json` +- `world_events.jsonl` +- `agent_runs.jsonl` +- `agent_events.jsonl` - `agent_messages.jsonl` -- é‡å¯åŽå¯æ¢å¤è¿è¡Œä¸­çš„任务 -### 3. watchdog 按进展续时 +### 4. 世界闭环已ç»è·‘通 -- 系统超时统一走全局 watchdog -- 还在推进的任务ä¸ä¼šå› ä¸ºå›ºå®šå¢™é’Ÿè¶…æ—¶è¢«ç›´æŽ¥æ€æŽ‰ -- 无进展时æ‰è¶…时,行为更接近真实工程执行 +当å‰å·²æ”¯æŒï¼š -### 4. é…ç½®å·¥ç¨‹åŒ–ï¼Œè€Œä¸æ˜¯ prompt 堆砌 +- 用户输入 -> 世界事件 +- NPC 感知 -> æ„å›¾ç”Ÿæˆ +- `move / speak / observe / interact / delegate / wait` +- ä»»åŠ¡ä¸Žèµ„æºæŽ¨è¿› +- 动æ€åˆ›å»º NPC +- 地图ã€åœ°ç‚¹å ä½ã€å®žä½“å ä½ã€ä»»åŠ¡ã€äº‹ä»¶æµå¯è§†åŒ– -- `config.json` ç®¡ç† agent registry -- `system_prompt_file -> AGENT.md` -- WebUI å¯ç¼–辑ã€çƒ­æ›´æ–°ã€æŸ¥çœ‹è¿è¡Œæ€ +### 5. WebUI å·²ç»æ˜¯ GM 控制å°é›å½¢ -### 5. Spec Coding(规范驱动开å‘) +- world snapshot +- 地点图 +- NPC detail +- entity detail +- quest board +- advance tick +- recent world events -- 明确需è¦ç¼–ç ä¸”å±žäºŽéž trivial 的任务å¯èµ° `spec.md -> tasks.md -> checklist.md` -- å°ä¿®å°è¡¥ã€è½»å¾®ä»£ç è°ƒæ•´ã€å•点改动默认ä¸å¯ç”¨è¿™å¥—æµç¨‹ -- `spec.md` 负责范围ã€å†³ç­–ã€æƒè¡¡ -- `tasks.md` 负责任务拆解和进度更新 -- `checklist.md` 负责最终完整性核查 -- 这三份文档是活文档,å…许在开å‘过程中æŒç»­ä¿®è®¢ - -### 6. 适åˆçœŸæ­£é•¿æœŸè¿è¡Œ - -- 本地优先 -- Go 原生 runtime -- å¤šé€šé“æŽ¥å…¥ -- Task Audit / Logs / Memory / Skills / Config / Agents 全链路闭环 - -## WebUI 亮点 ðŸ–¥ï¸ +## WebUI **Dashboard** ![ClawGo Dashboard](docs/assets/readme-dashboard.png) -**Agents 拓扑** +**World / Runtime** ![ClawGo Agents Topology](docs/assets/readme-agents.png) -**Config 工作å°** +**Config / Registry** ![ClawGo Config](docs/assets/readme-config.png) -## 快速开始 🚀 +## 快速开始 ### 1. 安装 @@ -118,20 +132,18 @@ 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"`: +- OAuth 凭è¯ä¼šè½æœ¬åœ° +- å¯è‡ªåŠ¨åŒæ­¥è´¦å·å¯ç”¨æ¨¡åž‹ +- åŒä¸€ provider å¯ç™»å½•多个账å·ï¼Œé¢åº¦ä¸è¶³æ—¶å¯è‡ªåŠ¨è½®æ¢ +- WebUI ä¹Ÿæ”¯æŒ OAuth 登录ã€å›žå¡« callback URLã€è®¾å¤‡ç ç¡®è®¤ã€ä¸Šä¼  `auth.json`ã€æŸ¥çœ‹å’Œåˆ é™¤è´¦å· -- 优先使用 `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` +如果åŒä¸€ provider åŒæ—¶æœ‰ `API key` 与 OAuth è´¦å·ï¼Œå»ºè®®ä½¿ç”¨ `auth: "hybrid"`: + +- 优先 `api_key` +- 触å‘é…é¢/陿µé”™è¯¯æ—¶è‡ªåŠ¨åˆ‡ OAuth è´¦å·æ±  +- 支æŒå¤šè´¦å·è½®æ¢ã€åŽå°é¢„刷新和è¿è¡Œåކ岿Œä¹…化 ### 4. å¯åЍ @@ -139,7 +151,7 @@ WebUI 也支æŒå‘èµ· OAuth 登录ã€å›žå¡« callback URLã€è®¾å¤‡ç ç¡®è®¤ã€ä¸Š ```bash clawgo agent -clawgo agent -m "Hello" +clawgo agent -m "我走进城门å£ï¼Œçœ‹çœ‹å®ˆå«åœ¨åšä»€ä¹ˆ" ``` 网关模å¼ï¼š @@ -160,44 +172,44 @@ WebUI: http://:/?token= ``` -## 架构概览 +## 世界模型 -默认å作模å¼ï¼š +当å‰ä¸–界è¿è¡Œæ—¶å›´ç»•这些概念组织: -```text -user -> main -> worker -> main -> user -``` +- `WorldState` + - 世界时钟 + - 地点 + - 全局事实 + - 实体 + - 活跃任务 +- `NPCState` + - 所在地点 + - 短期/长期目标 + - beliefs + - relationships + - inventory/assets + - private memory summary +- `WorldEvent` + - 用户输入 + - NPC 行为 + - è£å†³ç»“æžœ + - 状æ€å˜æ›´æ‘˜è¦ +- `ActionIntent` + - NPC 想åšä»€ä¹ˆ + - 䏿˜¯ä¸–界已ç»å‘生了什么 -当å‰ç³»ç»ŸåŒ…å«å››å±‚: +核心循环: -1. `main agent` - 负责用户入å£ã€è·¯ç”±ã€æ´¾å‘ã€æ±‡æ€» -2. `local subagents` - 在 `config.json -> agents.subagents` 中声明,使用独立 session å’Œ memory namespace -3. `node-backed branches` - è¿œç«¯èŠ‚ç‚¹ä½œä¸ºå—æŽ§ agent 分支挂载到主拓扑 -4. `runtime store` - ä¿å­˜è¿è¡Œæ€ã€çº¿ç¨‹ã€æ¶ˆæ¯ã€äº‹ä»¶å’Œå®¡è®¡æ•°æ® - -## 你能用它åšä»€ä¹ˆ - -- 🤖 本地长期è¿è¡Œçš„个人 Agent -- 🧪 `pm -> coder -> tester` è¿™ç§å¤š Agent å作链 -- 🌠本地主控 + 远端 node åˆ†æ”¯çš„åˆ†å¸ƒå¼æ‰§è¡Œ -- 🔠需è¦å¼ºè§‚测ã€å¼ºå®¡è®¡ã€å¼ºæ¢å¤çš„ Agent 系统 -- 🭠想把 promptã€agentã€å·¥å…·æƒé™ã€è¿è¡Œç­–略工程化管ç†çš„团队 -- 📠想把编ç è¿‡ç¨‹å˜æˆå¯è¿½è¸ªçš„ spec-driven delivery æµç¨‹ +1. ingest +2. perceive +3. decide +4. arbitrate +5. apply +6. render ## é…置结构 -当剿œ‰ä¸¤å±‚é…置视图: - -- è½ç›˜æ–‡ä»¶ä»ç„¶ä½¿ç”¨ä¸‹é¢çš„原始结构 -- WebUI 与è¿è¡Œæ—¶æŽ¥å£ä¼˜å…ˆä½¿ç”¨æ ‡å‡†åŒ–视图: - - `core` - - `runtime` - -原始é…置的推è结构: +当剿ލèé…置围绕 `agents.agents` 展开: ```json { @@ -207,83 +219,20 @@ user -> main -> worker -> main -> user "execution": {}, "summary_policy": {} }, - "router": { - "enabled": true, - "main_agent_id": "main", - "strategy": "rules_first", - "policy": { - "intent_max_input_chars": 1200, - "max_rounds_without_user": 200 + "agents": { + "main": { + "type": "agent", + "prompt_file": "agents/main/AGENT.md" }, - "rules": [] - }, - "communication": {}, - "subagents": { - "main": {}, - "coder": {}, - "tester": {} - } - } -} -``` - -说明: - -- `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` -- å¯ç”¨ä¸­çš„æœ¬åœ° 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" - } - ] + "guard": { + "kind": "npc", + "persona": "A cautious town guard", + "home_location": "gate", + "default_goals": ["patrol the square"] + }, + "coder": { + "type": "agent", + "prompt_file": "agents/coder/AGENT.md" } } } @@ -292,55 +241,33 @@ user -> main -> worker -> main -> user 说明: -- `webrtc` 建连失败时,调度层ä»ä¼šå›žé€€åˆ°çŽ°æœ‰ relay / tunnel 路径 -- Dashboardã€`status`ã€`/api/nodes` ä¼šæ˜¾ç¤ºå½“å‰ Node P2P 状æ€å’Œä¼šè¯æ‘˜è¦ -- 两å°å…¬ç½‘æœºå™¨çš„å®žç½‘éªŒè¯æµç¨‹è§ [docs/node-p2p-e2e.md](/Users/lpf/Desktop/project/clawgo/docs/node-p2p-e2e.md) +- 主é…置使用: + - `agents.defaults.execution` + - `agents.defaults.summary_policy` + - `agents.agents` +- 坿‰§è¡Œåž‹æœ¬åœ° agent 通常é…ç½® `prompt_file` +- 世界内 NPC 通过 `kind: "npc"` 进入 world runtime +- è¿œç«¯åˆ†æ”¯ä»æ”¯æŒï¼š + - `transport: "node"` + - `node_id` + - `parent_agent_id` +- WebUI 与è¿è¡Œæ—¶æŽ¥å£ä¼˜å…ˆæ¶ˆè´¹ normalized schema: + - `core.default_provider` + - `core.agents` + - `runtime.providers` -## MCP æœåŠ¡æ”¯æŒ - -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` 段è½ã€‚ - -## Prompt 文件约定 - -æŽ¨èæŠŠ agent prompt 独立为文件: - -- `agents/main/AGENT.md` -- `agents/coder/AGENT.md` -- `agents/tester/AGENT.md` - -é…置示例: - -```json -{ - "system_prompt_file": "agents/coder/AGENT.md" -} -``` - -规则: - -- 路径必须是 workspace 内相对路径 -- 仓库ä¸ä¼šå†…置这些示例文件 -- 用户或 agent workflow 需è¦è‡ªè¡Œåˆ›å»ºå®žé™…çš„ `AGENT.md` +å®Œæ•´ç¤ºä¾‹è§ [config.example.json](./config.example.json)。 ## 记忆与è¿è¡Œæ€ -ClawGo 䏿˜¯æ‰€æœ‰ agent 共用一份上下文。 +ClawGo 䏿˜¯æ‰€æœ‰è§’色共用一份上下文。 - `main` - - ä¿å­˜ä¸»è®°å¿†ä¸Žåä½œæ‘˜è¦ -- `subagent` - - 使用独立 session key - - 写入自己的 memory namespace + - ä¿å­˜ä¸»è®°å¿†ä¸Žä¸–界åä½œæ‘˜è¦ +- `agent / npc` + - 使用自己的 memory namespace 或 world 决策上下文 - `runtime store` - - æŒä¹…化任务ã€äº‹ä»¶ã€çº¿ç¨‹ã€æ¶ˆæ¯ + - æŒä¹…化任务ã€äº‹ä»¶ã€æ¶ˆæ¯ã€world 这带æ¥ä¸‰ä»¶äº‹ï¼š @@ -348,10 +275,22 @@ ClawGo 䏿˜¯æ‰€æœ‰ agent 共用一份上下文。 - 更好追踪 - 更清晰的执行边界 -## 当剿œ€é€‚åˆçš„人群 +## 适åˆåšä»€ä¹ˆ -- 想用 Go åš Agent Runtime 的开å‘者 -- 想è¦å¯è§†åŒ–多 Agent 拓扑和内部æµçš„团队 -- 䏿»¡è¶³äºŽâ€œèŠå¤© + promptâ€ï¼Œè€Œæƒ³è¦çœŸæ­£è¿è¡Œæ—¶èƒ½åŠ›çš„ç”¨æˆ· +- 自治 NPC 世界模拟 +- å°é•‡ / 地图 / 场景推进 +- 剧情驱动沙盒 +- 任务æ¿ã€èµ„æºã€å®žä½“交互 +- 本地主控 + 远端 node 分支的混åˆä¸–界 +- 需è¦å¼ºè§‚测ã€å¼ºæ¢å¤çš„æ¸¸æˆä¸–界核心 -如果你想快速上手,先看 [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json),å†è·‘一次 `make dev`。 +## Node P2P + +底层节点数æ®é¢ä»ç„¶ä¿ç•™ï¼Œæ”¯æŒï¼š + +- `websocket_tunnel` +- `webrtc` + +é»˜è®¤å…³é—­ï¼Œåªæœ‰æ˜¾å¼é…ç½® `gateway.nodes.p2p.enabled=true` æ‰å¯ç”¨ã€‚建议先用 `websocket_tunnel` 验è¯é“¾è·¯ï¼Œå†åˆ‡åˆ° `webrtc`。 + +如果你想直接上手,先看 [config.example.json](./config.example.json),å†è·‘一次 `make dev`。 diff --git a/README_EN.md b/README_EN.md index 186fbb1..6a6435f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,85 +1,108 @@ -# ClawGo 🦞 +# ClawGo -**A production-oriented, Go-native Agent Runtime.** +**A long-running world runtime for game-like simulation.** -ClawGo is not just another chat wrapper. It is a long-running, observable, recoverable, orchestrated runtime for real agent systems. +ClawGo is no longer positioned as a generic multi-agent shell. Its core model is now a **World Runtime** where `main` acts as the world mind, `npc` acts as autonomous characters, and structured state drives the simulation. -- 👀 **Observable**: agent topology, internal streams, task audit, and EKG visibility -- 🔠**Recoverable**: persisted runtime state, restart recovery, progress-aware watchdog -- 🧩 **Orchestrated**: `main agent -> subagent -> main`, with local and remote node branches -- âš™ï¸ **Operational**: `config.json`, `AGENT.md`, hot reload, WebUI, declarative registry +- **World mind**: `main` owns time, rules, arbitration, and rendering +- **Autonomous NPCs**: NPCs have persona, goals, memory, relations, and local perception +- **Structured world state**: world state, NPC state, and world events are persisted independently +- **Recoverable execution**: runtime and world state survive restart +- **Observable**: WebUI and APIs expose locations, NPCs, entities, quests, and recent events +- **Extensible**: low-level `providers`, `channels`, and node branches remain intact [中文](./README.md) -## Why ClawGo +## What It Is Now -Most agent projects stop at: +The default runtime loop is: -- a chat UI -- a tool runner -- a prompt layer +```text +user -> main(world mind) -> npc/agent intents -> arbitrate -> apply -> render -> user +``` -ClawGo focuses on runtime capabilities: +Roles: -- `main agent` handles entry, routing, dispatch, and merge -- `subagents` execute coding, testing, product, docs, and other focused tasks -- `node branches` attach remote nodes as controlled agent branches -- `runtime store` persists runs, events, threads, messages, and memory +- `main` + - user entry + - world event ingestion + - NPC wake-up and dispatch + - intent arbitration + - world state updates + - narrative rendering +- `npc` + - proposes `ActionIntent` + - never mutates world state directly + - acts from persona, goals, memory, and visible events +- `world store` + - persists world state, NPC state, event audit, and runtime records In one line: -> **ClawGo is an Agent Runtime, not just an Agent Chat shell.** +> **ClawGo is a world runtime, not just an agent chat shell.** -## Core Highlights ✨ +## Core Highlights -### 1. Observable multi-agent topology +### 1. `main` is the world will -- unified view of `main / subagents / remote branches` -- internal subagent streams are visible -- user-facing chat stays clean while internal collaboration remains inspectable +- `main` is not just an orchestrator +- every user message becomes a world event first +- final world truth is decided centrally by `main` -### 2. Recoverable execution +### 2. NPCs are autonomous -- `subagent_runs.jsonl` -- `subagent_events.jsonl` -- `threads.jsonl` +- each NPC has its own profile +- supports persona, traits, faction, home location, and default goals +- supports long-term goal behavior and event reactions +- supports delegation, messaging, and local perception + +### 3. The world is structured + +Core persisted files under `workspace/agents/runtime`: + +- `world_state.json` +- `npc_state.json` +- `world_events.jsonl` +- `agent_runs.jsonl` +- `agent_events.jsonl` - `agent_messages.jsonl` -- running tasks can recover after restart -### 3. Progress-aware watchdog +### 4. The simulation loop is real -- 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 +Current support includes: -### 4. Engineering-first agent configuration +- user input -> world event +- NPC perception -> intent generation +- `move / speak / observe / interact / delegate / wait` +- quest and resource progression +- runtime NPC creation +- map, occupancy, quest, entity, and recent-event visualization -- agent registry in `config.json` -- `system_prompt_file -> AGENT.md` -- WebUI for editing, hot reload, and runtime inspection +### 5. The WebUI is already a GM console -### 5. Built for long-running use +- world snapshot +- map view +- NPC detail +- entity detail +- quest board +- advance tick +- recent world events -- local-first -- Go-native runtime -- multi-channel capable -- end-to-end ops surface: Task Audit, Logs, Memory, Skills, Config, Agents - -## WebUI Highlights ðŸ–¥ï¸ +## WebUI **Dashboard** ![ClawGo Dashboard](docs/assets/readme-dashboard.png) -**Agent Topology** +**World / Runtime** ![ClawGo Agents Topology](docs/assets/readme-agents.png) -**Config Workspace** +**Config / Registry** ![ClawGo Config](docs/assets/readme-config.png) -## Quick Start 🚀 +## Quick Start ### 1. Install @@ -109,20 +132,18 @@ clawgo provider login codex clawgo provider login codex --manual ``` -After login, clawgo stores the OAuth session locally and syncs the models available to that account so the provider can be used directly. -Use `--manual` on a cloud server for callback-based OAuth (`codex`, `anthropic`, `antigravity`, `gemini`): clawgo prints the auth URL, you complete login in a desktop browser, then paste the final callback URL back into the terminal. -Device-flow OAuth (`kimi`, `qwen`) prints the verification URL and user code, then clawgo polls automatically after authorization without requiring a callback URL to be pasted back. -Repeat `clawgo provider login codex --manual` on the same provider to add multiple OAuth accounts; when one account hits quota or rate limits, clawgo automatically retries with the next logged-in account. -The WebUI can also start OAuth login, accept callback URL pasteback, confirm device-flow authorization, import `auth.json`, list accounts, refresh accounts, and delete accounts. +Notes: -If you have both an `API key` and OAuth accounts for the same upstream, prefer configuring that provider as `auth: "hybrid"`: +- OAuth credentials are stored locally +- available models are synced automatically +- multiple accounts per provider are supported +- WebUI can also start OAuth login, accept callback URL pasteback, confirm device flow, import `auth.json`, and manage accounts -- it uses `api_key` first -- when the API key hits quota/rate-limit style failures, it automatically falls back to the provider's OAuth account pool -- OAuth accounts still keep multi-account rotation, background pre-refresh, `auth.json` import, and WebUI management -- `oauth.cooldown_sec` controls how long a rate-limited OAuth account stays out of rotation; default is `900` -- the provider runtime panel shows current candidate ordering, the most recent successful credential, and recent hit/error history -- to persist runtime history across restarts, configure `runtime_persist`, `runtime_history_file`, and `runtime_history_max` on the provider +If you have both an `API key` and OAuth accounts for the same upstream, use `auth: "hybrid"`: + +- prefer `api_key` +- fall back to OAuth account pools on quota/rate-limit style failures +- keep account rotation, refresh, import, and runtime history ### 4. Start @@ -130,7 +151,7 @@ Interactive mode: ```bash clawgo agent -clawgo agent -m "Hello" +clawgo agent -m "I walk to the gate and check what the guard is doing." ``` Gateway mode: @@ -151,43 +172,44 @@ WebUI: http://:/?token= ``` -## Architecture +## World Model -Default collaboration flow: +The runtime is organized around: -```text -user -> main -> worker -> main -> user -``` +- `WorldState` + - world clock + - locations + - global facts + - entities + - active quests +- `NPCState` + - current location + - short-term and long-term goals + - beliefs + - relationships + - inventory/assets + - private memory summary +- `WorldEvent` + - user input + - NPC actions + - arbitration results + - state change summaries +- `ActionIntent` + - what an NPC wants to do + - not the final world mutation itself -ClawGo currently has four layers: +Core loop: -1. `main agent` - user-facing entry, routing, dispatch, and merge -2. `local subagents` - declared in `config.json -> agents.subagents`, with isolated sessions and memory namespaces -3. `node-backed branches` - remote nodes mounted as controlled agent branches -4. `runtime store` - persisted runtime, threads, messages, events, and audit data - -## What It Is Good For - -- 🤖 long-running local personal agents -- 🧪 multi-agent flows like `pm -> coder -> tester` -- 🌠local control with remote node branches -- 🔠systems that need strong observability, auditability, and recovery -- 🭠teams that want agent config, prompts, tool permissions, and runtime policy managed as code +1. ingest +2. perceive +3. decide +4. arbitrate +5. apply +6. render ## Config Layout -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: - - `core` - - `runtime` - -Recommended raw structure: +Recommended config now centers on `agents.agents`: ```json { @@ -197,83 +219,20 @@ Recommended raw structure: "execution": {}, "summary_policy": {} }, - "router": { - "enabled": true, - "main_agent_id": "main", - "strategy": "rules_first", - "policy": { - "intent_max_input_chars": 1200, - "max_rounds_without_user": 200 + "agents": { + "main": { + "type": "agent", + "prompt_file": "agents/main/AGENT.md" }, - "rules": [] - }, - "communication": {}, - "subagents": { - "main": {}, - "coder": {}, - "tester": {} - } - } -} -``` - -Notes: - -- `runtime_control` has been removed -- the current structure uses: - - `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` -- runtime panels now consume the unified `runtime snapshot / runtime live` -- enabled local subagents must define `system_prompt_file` -- remote branches require: - - `transport: "node"` - - `node_id` - - `parent_agent_id` - -See [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json) for a full example. - -## Node P2P - -The remote node data plane supports: - -- `websocket_tunnel` -- `webrtc` - -It remains disabled by default. Node P2P is only enabled when `gateway.nodes.p2p.enabled=true` is set explicitly. In practice, start with `websocket_tunnel`, then switch to `webrtc` after validating connectivity. - -For `webrtc`, these two fields matter: - -- `stun_servers` - - legacy-compatible STUN list -- `ice_servers` - - the preferred structured format - - may include `stun:`, `turn:`, and `turns:` URLs - - `turn:` / `turns:` entries require both `username` and `credential` - -Example: - -```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" - } - ] + "guard": { + "kind": "npc", + "persona": "A cautious town guard", + "home_location": "gate", + "default_goals": ["patrol the square"] + }, + "coder": { + "type": "agent", + "prompt_file": "agents/coder/AGENT.md" } } } @@ -282,66 +241,56 @@ Example: Notes: -- when `webrtc` session setup fails, dispatch still falls back to the existing relay / tunnel path -- Dashboard, `status`, and `/api/nodes` expose the current Node P2P runtime summary -- a reusable public-network validation flow is documented in [docs/node-p2p-e2e.md](/Users/lpf/Desktop/project/clawgo/docs/node-p2p-e2e.md) +- the main config surface is now: + - `agents.defaults.execution` + - `agents.defaults.summary_policy` + - `agents.agents` +- executable local agents usually define `prompt_file` +- world NPCs enter the world runtime through `kind: "npc"` +- remote branches still support: + - `transport: "node"` + - `node_id` + - `parent_agent_id` +- normalized config and runtime APIs primarily expose: + - `core.default_provider` + - `core.agents` + - `runtime.providers` -## MCP Server Support - -ClawGo now supports `stdio`, `http`, `streamable_http`, and `sse` MCP servers through `tools.mcp`. - -- declare each server under `config.json -> tools.mcp.servers` -- the bridge supports `list_servers`, `list_tools`, `call_tool`, `list_resources`, `read_resource`, `list_prompts`, and `get_prompt` -- on startup, ClawGo discovers remote MCP tools and registers them as local tools using the `mcp____` naming pattern -- with `permission=workspace` (default), `working_dir` is resolved inside the workspace and must remain within it -- with `permission=full`, `working_dir` may point to any absolute path including `/`, but access still inherits the permissions of the Linux user running `clawgo` - -See the `tools.mcp` section in [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json). - -## Prompt File Convention - -Keep agent prompts in dedicated files: - -- `agents/main/AGENT.md` -- `agents/coder/AGENT.md` -- `agents/tester/AGENT.md` - -Example: - -```json -{ - "system_prompt_file": "agents/coder/AGENT.md" -} -``` - -Rules: - -- the path must be relative to the workspace -- the repo does not ship these example files -- users or agent workflows should create the actual `AGENT.md` files +See [config.example.json](./config.example.json) for a complete example. ## Memory and Runtime -ClawGo does not treat all agents as one shared context. +ClawGo does not treat every actor as one shared context. - `main` - - keeps workspace-level memory and collaboration summaries -- `subagent` - - uses its own session key - - writes to its own memory namespace + - keeps world-level memory and summaries +- `agent / npc` + - uses its own memory namespace or world decision context - `runtime store` - - persists runs, events, threads, and messages + - persists tasks, events, messages, and world state -This gives you: +That gives you: - better recovery - better traceability - clearer execution boundaries -## Best Fit +## What It Is Good For -- developers building agent runtimes in Go -- teams that want visible multi-agent topology and internal collaboration streams -- users who need more than “chat + prompt†+- autonomous NPC world simulation +- town / map / scene progression +- quest, resource, and entity interaction +- story-driven sandbox loops +- hybrid local control with remote node branches +- systems that need strong observability and recovery -If you want a working starting point, open [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json) and run `make dev`. +## Node P2P + +The low-level node data plane is still available: + +- `websocket_tunnel` +- `webrtc` + +It stays disabled by default. Enable it explicitly with `gateway.nodes.p2p.enabled=true`. In practice, validate with `websocket_tunnel` first, then switch to `webrtc`. + +If you want a working starting point, open [config.example.json](./config.example.json) and run `make dev`. diff --git a/cmd/artifacts/node/camera-test.jpg b/cmd/artifacts/node/camera-test.jpg new file mode 100644 index 0000000..93ebd05 --- /dev/null +++ b/cmd/artifacts/node/camera-test.jpg @@ -0,0 +1 @@ +camera-bytes \ No newline at end of file diff --git a/cmd/artifacts/node/camera-test.mp4 b/cmd/artifacts/node/camera-test.mp4 new file mode 100644 index 0000000..bcfd37a --- /dev/null +++ b/cmd/artifacts/node/camera-test.mp4 @@ -0,0 +1 @@ +video-bytes \ No newline at end of file diff --git a/cmd/artifacts/node/screen-test.mp4 b/cmd/artifacts/node/screen-test.mp4 new file mode 100644 index 0000000..4d72d69 --- /dev/null +++ b/cmd/artifacts/node/screen-test.mp4 @@ -0,0 +1 @@ +screen-video \ No newline at end of file diff --git a/cmd/artifacts/node/screen-test.png b/cmd/artifacts/node/screen-test.png new file mode 100644 index 0000000..2371c64 --- /dev/null +++ b/cmd/artifacts/node/screen-test.png @@ -0,0 +1 @@ +‰PNG \ No newline at end of file diff --git a/cmd/cmd_gateway.go b/cmd/cmd_gateway.go index 3588681..aa12671 100644 --- a/cmd/cmd_gateway.go +++ b/cmd/cmd_gateway.go @@ -223,8 +223,8 @@ 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.SetRuntimeAdminHandler(func(cctx context.Context, action string, args map[string]interface{}) (interface{}, error) { + return loop.HandleRuntimeAdmin(cctx, action, args) }) registryServer.SetNodeDispatchHandler(func(cctx context.Context, req nodes.Request, mode string) (nodes.Response, error) { return loop.DispatchNodeRequest(cctx, req, mode) diff --git a/cmd/cmd_node.go b/cmd/cmd_node.go index f4f174a..de52f37 100644 --- a/cmd/cmd_node.go +++ b/cmd/cmd_node.go @@ -26,7 +26,6 @@ import ( "github.com/YspCoder/clawgo/pkg/nodes" "github.com/YspCoder/clawgo/pkg/providers" "github.com/YspCoder/clawgo/pkg/runtimecfg" - "github.com/YspCoder/clawgo/pkg/tools" "github.com/gorilla/websocket" "github.com/pion/webrtc/v4" ) @@ -384,8 +383,8 @@ func nodeAgentsFromConfig(cfg *config.Config) []nodes.AgentInfo { if cfg == nil { return nil } - items := make([]nodes.AgentInfo, 0, len(cfg.Agents.Subagents)) - for agentID, subcfg := range cfg.Agents.Subagents { + items := make([]nodes.AgentInfo, 0, len(cfg.Agents.Agents)) + for agentID, subcfg := range cfg.Agents.Agents { id := strings.TrimSpace(agentID) if id == "" || !subcfg.Enabled { continue @@ -394,7 +393,7 @@ func nodeAgentsFromConfig(cfg *config.Config) []nodes.AgentInfo { ID: id, DisplayName: strings.TrimSpace(subcfg.DisplayName), Role: strings.TrimSpace(subcfg.Role), - Type: strings.TrimSpace(subcfg.Type), + Type: firstNonEmptyString(strings.TrimSpace(subcfg.Kind), strings.TrimSpace(subcfg.Type), "agent"), Transport: strings.TrimSpace(subcfg.Transport), ParentAgentID: strings.TrimSpace(subcfg.ParentAgentID), }) @@ -833,23 +832,11 @@ 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), - }) + sessionKey := fmt.Sprintf("node:%s:%s", info.ID, remoteAgentID) + result, err := loop.ProcessDirectWithOptions(ctx, strings.TrimSpace(req.Task), sessionKey, "node", info.ID, remoteAgentID, nil) 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) - } - } artifacts, err := collectNodeArtifacts(executor.workspace, req.Args) if err != nil { return nodes.Response{}, err @@ -862,7 +849,7 @@ func executeNodeAgentTask(ctx context.Context, info nodes.NodeInfo, req nodes.Re Payload: map[string]interface{}{ "transport": "clawgo-local", "agent_id": remoteAgentID, - "result": result, + "result": strings.TrimSpace(result), "artifacts": artifacts, }, }, nil diff --git a/cmd/cmd_node_test.go b/cmd/cmd_node_test.go index e1333c4..18a8a72 100644 --- a/cmd/cmd_node_test.go +++ b/cmd/cmd_node_test.go @@ -139,21 +139,21 @@ func TestNodeAgentsFromConfigCollectsEnabledAgents(t *testing.T) { t.Parallel() cfg := config.DefaultConfig() - cfg.Agents.Subagents["main"] = config.SubagentConfig{ + cfg.Agents.Agents["main"] = config.AgentConfig{ Enabled: true, - Type: "router", + Type: "agent", DisplayName: "Main Agent", Role: "orchestrator", } - cfg.Agents.Subagents["coder"] = config.SubagentConfig{ + cfg.Agents.Agents["coder"] = config.AgentConfig{ Enabled: true, - Type: "worker", + Type: "agent", DisplayName: "Code Agent", Role: "code", } - cfg.Agents.Subagents["tester"] = config.SubagentConfig{ + cfg.Agents.Agents["tester"] = config.AgentConfig{ Enabled: false, - Type: "worker", + Type: "agent", DisplayName: "Test Agent", Role: "test", } @@ -211,9 +211,9 @@ func TestExecuteNodeRequestRunsLocalMainAgentTask(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "workspace") - cfg.Agents.Subagents["main"] = config.SubagentConfig{ + cfg.Agents.Agents["main"] = config.AgentConfig{ Enabled: true, - Type: "router", + Type: "agent", Role: "orchestrator", } if err := config.SaveConfig(globalConfigPathOverride, cfg); err != nil { @@ -236,7 +236,7 @@ func TestExecuteNodeRequestRunsLocalMainAgentTask(t *testing.T) { } } -func TestExecuteNodeRequestRunsLocalSubagentTask(t *testing.T) { +func TestExecuteNodeRequestRunsLocalAgentTask(t *testing.T) { prevCfg := globalConfigPathOverride prevProviderFactory := nodeProviderFactory prevLoopFactory := nodeAgentLoopFactory @@ -256,14 +256,14 @@ func TestExecuteNodeRequestRunsLocalSubagentTask(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "workspace") - cfg.Agents.Subagents["main"] = config.SubagentConfig{ + cfg.Agents.Agents["main"] = config.AgentConfig{ Enabled: true, - Type: "router", + Type: "agent", Role: "orchestrator", } - cfg.Agents.Subagents["coder"] = config.SubagentConfig{ + cfg.Agents.Agents["coder"] = config.AgentConfig{ Enabled: true, - Type: "worker", + Type: "agent", Role: "code", } if err := os.MkdirAll(filepath.Join(cfg.Agents.Defaults.Workspace, "out"), 0755); err != nil { diff --git a/cmd/cmd_status.go b/cmd/cmd_status.go index 70f90ca..66a99f1 100644 --- a/cmd/cmd_status.go +++ b/cmd/cmd_status.go @@ -113,14 +113,14 @@ func statusCmd() { 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"} { + for _, k := range []string{"main", "cron", "agent", "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:") + if recent, err := collectRecentAgentSessions(sessionsDir, 5); err == nil && len(recent) > 0 { + fmt.Println("Recent Agent Sessions:") for _, key := range recent { fmt.Printf(" - %s\n", key) } @@ -389,7 +389,7 @@ func collectSkillExecStats(path string) (int, int, int, float64, string, error) return total, okCnt, failCnt, reasonCoverage, topSkill, nil } -func collectRecentSubagentSessions(sessionsDir string, limit int) ([]string, error) { +func collectRecentAgentSessions(sessionsDir string, limit int) ([]string, error) { indexPath := filepath.Join(sessionsDir, "sessions.json") data, err := os.ReadFile(indexPath) if err != nil { @@ -408,7 +408,7 @@ func collectRecentSubagentSessions(sessionsDir string, limit int) ([]string, err } items := make([]item, 0) for key, row := range index { - if strings.ToLower(strings.TrimSpace(row.Kind)) != "subagent" { + if strings.ToLower(strings.TrimSpace(row.Kind)) != "agent" { continue } items = append(items, item{key: key, updated: row.UpdatedAt}) diff --git a/pkg/agent/subagent_node_test.go b/pkg/agent/agent_node_test.go similarity index 73% rename from pkg/agent/subagent_node_test.go rename to pkg/agent/agent_node_test.go index 9c5106d..ec02757 100644 --- a/pkg/agent/subagent_node_test.go +++ b/pkg/agent/agent_node_test.go @@ -9,7 +9,7 @@ import ( "github.com/YspCoder/clawgo/pkg/tools" ) -func TestDispatchNodeSubagentTaskUsesNodeAgentTask(t *testing.T) { +func TestDispatchNodeAgentTaskUsesNodeAgentTask(t *testing.T) { manager := nodes.NewManager() manager.Upsert(nodes.NodeInfo{ ID: "edge-dev", @@ -44,16 +44,18 @@ func TestDispatchNodeSubagentTaskUsesNodeAgentTask(t *testing.T) { Relay: &nodes.HTTPRelayTransport{Manager: manager}, }, } - out, err := loop.dispatchNodeSubagentTask(context.Background(), &tools.SubagentTask{ - ID: "subagent-1", - AgentID: "node.edge-dev.coder", - Transport: "node", - NodeID: "edge-dev", - ParentAgentID: "main", - Task: "Implement fix on remote node", + out, err := loop.dispatchNodeAgentTask(context.Background(), &tools.AgentTask{ + ID: "agent-1", + AgentID: "node.edge-dev.coder", + Target: &tools.TargetRef{ + Transport: "node", + NodeID: "edge-dev", + ParentAgentID: "main", + }, + Task: "Implement fix on remote node", }) if err != nil { - t.Fatalf("dispatchNodeSubagentTask failed: %v", err) + t.Fatalf("dispatchNodeAgentTask 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/agent_prompt_test.go similarity index 74% rename from pkg/agent/subagent_prompt_test.go rename to pkg/agent/agent_prompt_test.go index 23f8c39..14340f8 100644 --- a/pkg/agent/subagent_prompt_test.go +++ b/pkg/agent/agent_prompt_test.go @@ -9,7 +9,7 @@ import ( "github.com/YspCoder/clawgo/pkg/tools" ) -func TestBuildSubagentTaskInputPrefersPromptFile(t *testing.T) { +func TestBuildAgentTaskInputPrefersPromptFile(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,9 +18,11 @@ func TestBuildSubagentTaskInputPrefersPromptFile(t *testing.T) { t.Fatalf("write AGENT failed: %v", err) } loop := &AgentLoop{workspace: workspace} - input := loop.buildSubagentTaskInput(&tools.SubagentTask{ - Task: "implement login flow", - SystemPromptFile: "agents/coder/AGENT.md", + input := loop.buildAgentTaskInput(&tools.AgentTask{ + Task: "implement login flow", + ExecutionPolicy: &tools.ExecutionPolicy{ + PromptFile: "agents/coder/AGENT.md", + }, }) if !strings.Contains(input, "coder-file-policy") { t.Fatalf("expected prompt file content, got: %s", input) @@ -30,9 +32,9 @@ func TestBuildSubagentTaskInputPrefersPromptFile(t *testing.T) { } } -func TestBuildSubagentTaskInputWithoutPromptFileUsesTaskOnly(t *testing.T) { +func TestBuildAgentTaskInputWithoutPromptFileUsesTaskOnly(t *testing.T) { loop := &AgentLoop{workspace: t.TempDir()} - input := loop.buildSubagentTaskInput(&tools.SubagentTask{ + input := loop.buildAgentTaskInput(&tools.AgentTask{ Task: "run regression", }) if strings.Contains(input, "test inline prompt") { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index cc069ec..661c619 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -65,14 +65,14 @@ type AgentLoop struct { sessionProvider map[string]string streamMu sync.Mutex sessionStreamed map[string]bool - subagentManager *tools.SubagentManager - subagentRouter *tools.SubagentRouter - subagentConfigTool *tools.SubagentConfigTool + agentManager *tools.AgentManager + agentDispatcher *tools.AgentDispatcher + worldRuntime *WorldRuntime nodeRouter *nodes.Router configPath string - subagentDigestMu sync.Mutex - subagentDigestDelay time.Duration - subagentDigests map[string]*subagentDigestState + agentDigestMu sync.Mutex + agentDigestDelay time.Duration + agentDigests map[string]*agentDigestState } type providerCandidate struct { @@ -81,7 +81,7 @@ type providerCandidate struct { model string } -type subagentDigestItem struct { +type agentDigestItem struct { agentID string reason string status string @@ -89,10 +89,10 @@ type subagentDigestItem struct { resultSummary string } -type subagentDigestState struct { +type agentDigestState struct { channel string chatID string - items map[string]subagentDigestItem + items map[string]agentDigestItem dueAt time.Time } @@ -222,9 +222,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) { @@ -370,16 +367,12 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers }) toolsRegistry.Register(messageTool) - // 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)) + agentManager := tools.NewAgentManager(provider, workspace, msgBus) + agentDispatcher := tools.NewAgentDispatcher(agentManager) + worldRuntime := NewWorldRuntime(workspace, agentManager.ProfileStore(), agentDispatcher, agentManager) + toolsRegistry.Register(tools.NewWorldTool(worldRuntime)) + if store := agentManager.ProfileStore(); store != nil { + toolsRegistry.Register(tools.NewAgentProfileTool(store)) } toolsRegistry.Register(tools.NewSessionsTool( func(limit int) []tools.SessionInfo { @@ -439,17 +432,17 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers sessionStreamed: map[string]bool{}, providerResponses: map[string]config.ProviderResponsesConfig{}, telegramStreaming: cfg.Channels.Telegram.Streaming, - subagentManager: subagentManager, - subagentRouter: subagentRouter, - subagentConfigTool: subagentConfigTool, + agentManager: agentManager, + agentDispatcher: agentDispatcher, + worldRuntime: worldRuntime, nodeRouter: nodesRouter, - subagentDigestDelay: 5 * time.Second, - subagentDigests: map[string]*subagentDigestState{}, + agentDigestDelay: 5 * time.Second, + agentDigests: map[string]*agentDigestState{}, } if _, primaryModel := config.ParseProviderModelRef(cfg.Agents.Defaults.Model.Primary); strings.TrimSpace(primaryModel) != "" { loop.model = strings.TrimSpace(primaryModel) } - go loop.runSubagentDigestTicker() + go loop.runAgentDigestTicker() // Initialize provider fallback chain (primary + inferred providers). loop.providerChain = []providerCandidate{} loop.providerPool = map[string]providers.LLMProvider{} @@ -521,33 +514,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) { + // Inject recursive run logic so agents can use full tool-calling flows. + agentManager.SetRunFunc(func(ctx context.Context, task *tools.AgentTask) (string, error) { if task == nil { - return "", fmt.Errorf("subagent task is nil") + return "", fmt.Errorf("agent task is nil") } - if strings.EqualFold(strings.TrimSpace(task.Transport), "node") { - return loop.dispatchNodeSubagentTask(ctx, task) + if tools.IsWorldDecisionTask(task) { + return loop.runWorldDecisionTask(ctx, task) } - sessionKey := strings.TrimSpace(task.SessionKey) - if sessionKey == "" { - sessionKey = fmt.Sprintf("subagent:%s", strings.TrimSpace(task.ID)) + if strings.EqualFold(strings.TrimSpace(tools.TargetTransport(task.Target)), "node") { + return loop.dispatchNodeAgentTask(ctx, task) } - taskInput := loop.buildSubagentTaskInput(task) - ns := normalizeMemoryNamespace(task.MemoryNS) + sessionKey := tools.BuildAgentSessionKey(task.AgentID, task.ID) + taskInput := loop.buildAgentTaskInput(task) + ns := normalizeMemoryNamespace(loop.agentManager.ResolveMemoryNamespace(task.AgentID)) ctx = withMemoryNamespaceContext(ctx, ns) - ctx = withToolAllowlistContext(ctx, task.ToolAllowlist) - channel := strings.TrimSpace(task.OriginChannel) - if channel == "" { - channel = "cli" - } - chatID := strings.TrimSpace(task.OriginChatID) - if chatID == "" { - chatID = "direct" - } + ctx = withToolAllowlistContext(ctx, tools.ToolAllowlistFromPolicy(task.ExecutionPolicy)) + channel, chatID := tools.OriginValues(task.Origin) msg := bus.InboundMessage{ Channel: channel, - SenderID: "subagent", + SenderID: "agent", ChatID: chatID, Content: taskInput, SessionKey: sessionKey, @@ -555,7 +541,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers "memory_namespace": ns, "memory_ns": ns, "disable_planning": "true", - "trigger": "subagent", + "trigger": "agent", }, } return loop.processMessage(ctx, msg) @@ -564,18 +550,18 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers return loop } -func (al *AgentLoop) dispatchNodeSubagentTask(ctx context.Context, task *tools.SubagentTask) (string, error) { +func (al *AgentLoop) dispatchNodeAgentTask(ctx context.Context, task *tools.AgentTask) (string, error) { if al == nil || task == nil { - return "", fmt.Errorf("node subagent task is nil") + return "", fmt.Errorf("node agent task is nil") } if al.nodeRouter == nil { - return "", fmt.Errorf("node router is not configured") + return "", fmt.Errorf("node transport is not configured") } - nodeID := strings.TrimSpace(task.NodeID) + nodeID := strings.TrimSpace(tools.TargetNodeID(task.Target)) if nodeID == "" { - return "", fmt.Errorf("node-backed subagent %q missing node_id", task.AgentID) + return "", fmt.Errorf("node-backed agent %q missing node_id", task.AgentID) } - taskInput := loopTaskInputForNode(task) + taskInput := loopAgentTaskInputForNode(task) reqArgs := map[string]interface{}{} if remoteAgentID := remoteAgentIDForNodeBranch(task.AgentID, nodeID); remoteAgentID != "" { reqArgs["remote_agent_id"] = remoteAgentID @@ -618,11 +604,11 @@ func remoteAgentIDForNodeBranch(agentID, nodeID string) string { return remote } -func loopTaskInputForNode(task *tools.SubagentTask) string { +func loopAgentTaskInputForNode(task *tools.AgentTask) string { if task == nil { return "" } - if parent := strings.TrimSpace(task.ParentAgentID); parent != "" { + if parent := strings.TrimSpace(tools.TargetParentAgentID(task.Target)); parent != "" { return fmt.Sprintf("Parent Agent: %s\nSubtree Branch: %s\n\nTask:\n%s", parent, task.AgentID, strings.TrimSpace(task.Task)) } return strings.TrimSpace(task.Task) @@ -641,20 +627,176 @@ func nodeAgentTaskResult(payload map[string]interface{}) string { return "" } -func (al *AgentLoop) buildSubagentTaskInput(task *tools.SubagentTask) string { +func (al *AgentLoop) buildAgentTaskInput(task *tools.AgentTask) string { if task == nil { return "" } + if tools.IsWorldDecisionTask(task) { + return strings.TrimSpace(task.Task) + } taskText := strings.TrimSpace(task.Task) - if promptFile := strings.TrimSpace(task.SystemPromptFile); promptFile != "" { - if promptText := al.readSubagentPromptFile(promptFile); promptText != "" { + if promptFile := tools.PromptFileFromPolicy(task.ExecutionPolicy); promptFile != "" { + if promptText := al.readAgentPromptFile(promptFile); promptText != "" { return fmt.Sprintf("Role Profile Policy (%s):\n%s\n\nTask:\n%s", promptFile, promptText, taskText) } } return taskText } -func (al *AgentLoop) readSubagentPromptFile(relPath string) string { +func (al *AgentLoop) runWorldDecisionTask(ctx context.Context, task *tools.AgentTask) (string, error) { + if task == nil { + return "", fmt.Errorf("world decision task is nil") + } + if out, ok := al.tryWorldDecisionLLM(ctx, task); ok { + return out, nil + } + intent := map[string]interface{}{ + "actor_id": task.AgentID, + "action": "wait", + } + worldDecision := task.WorldDecision + npcID := strings.TrimSpace(task.AgentID) + location := "" + if worldDecision != nil && worldDecision.NPCSnapshot != nil { + if v := tools.MapStringArg(worldDecision.NPCSnapshot, "current_location"); v != "" { + location = v + } + } + for _, evt := range visibleWorldEvents(worldDecision) { + if strings.EqualFold(tools.MapStringArg(evt, "type"), "user_input") { + if loc := tools.MapStringArg(evt, "location_id"); location == "" || loc == "" || loc == location { + intent["action"] = "speak" + intent["speech"] = fmt.Sprintf("%s notices the user: %s", firstNonEmptyAgentValue(worldSnapshotMap(worldDecision, "npc"), "display_name", npcID), strings.TrimSpace(tools.MapStringArg(evt, "content"))) + intent["internal_reasoning_summary"] = "responded to a nearby user event" + break + } + } + } + if intent["action"] == "wait" && worldDecision != nil && worldDecision.NPCSnapshot != nil { + for _, item := range tools.MapStringListArg(worldDecision.NPCSnapshot, "goals_long_term") { + text := strings.ToLower(strings.TrimSpace(item)) + if strings.Contains(text, "patrol") { + if target := firstNeighborFromSnapshot(worldSnapshotMap(worldDecision, "world"), location); target != "" { + intent["action"] = "move" + intent["target_location"] = target + intent["internal_reasoning_summary"] = "continues a patrol route" + break + } + } + if strings.Contains(text, "watch") || strings.Contains(text, "guard") || strings.Contains(text, "observe") { + intent["action"] = "observe" + intent["internal_reasoning_summary"] = "keeps watch over the area" + break + } + } + } + data, err := json.Marshal(intent) + if err != nil { + return "", err + } + return string(data), nil +} + +func (al *AgentLoop) tryWorldDecisionLLM(ctx context.Context, task *tools.AgentTask) (string, bool) { + if al == nil || task == nil || task.WorldDecision == nil || al.provider == nil { + return "", false + } + system := strings.TrimSpace(`You are an NPC mind inside a simulated world. +Return exactly one JSON object and nothing else. +Rules: +- You do not change the world directly. +- Choose one action from: move, speak, observe, interact, delegate, wait. +- Prefer actions consistent with persona, goals, current location, and visible events. +- If information is insufficient, return {"actor_id":"...","action":"wait"}. +- Keep speech short. +JSON fields: +actor_id, action, target_location, target_entity, target_agent, speech, internal_reasoning_summary, proposed_effects`) + user := strings.TrimSpace(task.Task) + if user == "" { + user = "{}" + } + resp, err := al.provider.Chat(ctx, []providers.Message{ + {Role: "system", Content: system}, + {Role: "user", Content: user}, + }, nil, al.provider.GetDefaultModel(), map[string]interface{}{ + "max_tokens": 512, + "temperature": 0.2, + }) + if err != nil || resp == nil { + return "", false + } + content := strings.TrimSpace(resp.Content) + if content == "" { + return "", false + } + if _, err := parseWorldDecisionContent(content); err != nil { + return "", false + } + return content, true +} + +func visibleWorldEvents(ctx *tools.WorldDecisionContext) []map[string]interface{} { + if ctx == nil { + return nil + } + return ctx.VisibleEvents +} + +func worldSnapshotMap(ctx *tools.WorldDecisionContext, kind string) map[string]interface{} { + if ctx == nil { + return nil + } + switch kind { + case "npc": + return ctx.NPCSnapshot + default: + return ctx.WorldSnapshot + } +} + +func parseWorldDecisionContent(raw string) (map[string]interface{}, error) { + raw = strings.TrimSpace(raw) + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start >= 0 && end >= start { + raw = raw[start : end+1] + } + var out map[string]interface{} + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil, err + } + return out, nil +} + +func firstNonEmptyAgentValue(m map[string]interface{}, key, fallback string) string { + if m != nil { + if value := tools.MapStringArg(m, key); value != "" { + return value + } + } + return fallback +} + +func firstNeighborFromSnapshot(snapshot map[string]interface{}, location string) string { + if snapshot == nil { + return "" + } + locs, ok := snapshot["locations"].(map[string]interface{}) + if !ok { + return "" + } + raw, ok := locs[location].(map[string]interface{}) + if !ok { + return "" + } + neighbors, ok := raw["neighbors"].([]interface{}) + if !ok || len(neighbors) == 0 { + return "" + } + return strings.TrimSpace(fmt.Sprint(neighbors[0])) +} + +func (al *AgentLoop) readAgentPromptFile(relPath string) string { if al == nil { return "" } @@ -1150,7 +1292,7 @@ func loadHeartbeatAckToken(workspace string) string { func (al *AgentLoop) prepareOutbound(msg bus.InboundMessage, response string) (bus.OutboundMessage, bool) { if shouldDropNoReply(response) { - if fallback, ok := fallbackSubagentNotification(msg); ok { + if fallback, ok := fallbackAgentNotification(msg); ok { response = fallback } else { return bus.OutboundMessage{}, false @@ -1163,7 +1305,7 @@ func (al *AgentLoop) prepareOutbound(msg bus.InboundMessage, response string) (b clean, replyToID := parseReplyTag(response, currentMsgID) clean = strings.TrimSpace(clean) if clean == "" { - if fallback, ok := fallbackSubagentNotification(msg); ok { + if fallback, ok := fallbackAgentNotification(msg); ok { clean = fallback } else { return bus.OutboundMessage{}, false @@ -1272,24 +1414,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } else { specTaskRef = normalizeSpecCodingTaskRef(taskRef) } - if routed, ok, routeErr := al.maybeAutoRoute(ctx, msg); ok { - if routeErr != nil && specTaskRef.Summary != "" { - if err := al.maybeReopenSpecCodingTask(specTaskRef, msg.Content, routeErr.Error()); err != nil { - logger.WarnCF("agent", logger.C0172, map[string]interface{}{ - "session_key": msg.SessionKey, - "error": err.Error(), - }) - } - } - if routeErr == nil && specTaskRef.Summary != "" { - if err := al.maybeCompleteSpecCodingTask(specTaskRef, routed); err != nil { - logger.WarnCF("agent", logger.C0172, map[string]interface{}{ - "session_key": msg.SessionKey, - "error": err.Error(), - }) - } - } - return routed, routeErr + if al.worldRuntime != nil && al.worldRuntime.Enabled() && al.getTrigger(msg) == "user" && memoryNamespace == "main" { + return al.worldRuntime.HandleUserInput(ctx, msg.Content, msg.Channel, msg.ChatID) } history := al.sessions.GetHistory(msg.SessionKey) @@ -1593,7 +1719,7 @@ func (al *AgentLoop) appendDailySummaryLog(msg bus.InboundMessage, response stri logger.WarnCF("agent", logger.C0158, map[string]interface{}{logger.FieldError: err.Error()}) } if namespace != "main" { - mainLine := al.buildMainMemorySubagentEntry(msg, namespace, respText) + mainLine := al.buildMainMemoryAgentEntry(msg, namespace, respText) if err := NewMemoryStore(al.workspace).AppendToday(mainLine); err != nil { logger.WarnCF("agent", logger.C0158, map[string]interface{}{"target": "main", logger.FieldError: err.Error()}) } @@ -1611,9 +1737,9 @@ func (al *AgentLoop) buildDailySummaryEntry(msg bus.InboundMessage, namespace, r ) } -func (al *AgentLoop) buildMainMemorySubagentEntry(msg bus.InboundMessage, namespace, response string) string { +func (al *AgentLoop) buildMainMemoryAgentEntry(msg bus.InboundMessage, namespace, response string) string { title := al.dailySummaryTitle(msg, namespace) - return fmt.Sprintf("## %s %s\n\n- Subagent: %s\n- Did: %s\n- Session: %s", + return fmt.Sprintf("## %s %s\n\n- Agent: %s\n- Did: %s\n- Session: %s", time.Now().Format("15:04"), title, al.memoryAgentTitle(namespace), @@ -1638,7 +1764,7 @@ func (al *AgentLoop) memoryAgentTitle(namespace string) string { } cfg := runtimecfg.Get() if cfg != nil { - if subcfg, ok := cfg.Agents.Subagents[ns]; ok { + if subcfg, ok := cfg.Agents.Agents[ns]; ok { if name := strings.TrimSpace(subcfg.DisplayName); name != "" { return name } @@ -1681,7 +1807,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe "chat_id": msg.ChatID, }) - if al.handleSubagentSystemMessage(msg) { + if al.handleAgentSystemMessage(msg) { return "", nil } @@ -1824,8 +1950,8 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe return finalContent, nil } -func (al *AgentLoop) handleSubagentSystemMessage(msg bus.InboundMessage) bool { - if !isSubagentSystemMessage(msg) { +func (al *AgentLoop) handleAgentSystemMessage(msg bus.InboundMessage) bool { + if !isAgentSystemMessage(msg) { return false } reason := "" @@ -1839,12 +1965,12 @@ func (al *AgentLoop) handleSubagentSystemMessage(msg bus.InboundMessage) bool { status = strings.TrimSpace(msg.Metadata["status"]) } if agentID == "" { - agentID = strings.TrimSpace(strings.TrimPrefix(msg.SenderID, "subagent:")) + agentID = strings.TrimSpace(strings.TrimPrefix(msg.SenderID, "agent:")) } if taskSummary == "" || resultSummary == "" { - taskSummary, resultSummary = parseSubagentSystemContent(msg.Content) + taskSummary, resultSummary = parseAgentSystemContent(msg.Content) } - al.enqueueSubagentDigest(msg, subagentDigestItem{ + al.enqueueAgentDigest(msg, agentDigestItem{ agentID: agentID, reason: reason, status: status, @@ -1919,7 +2045,7 @@ func filterToolDefinitionsByContext(ctx context.Context, toolDefs []map[string]i if name == "" { continue } - if isToolNameAllowed(allow, name) || isImplicitlyAllowedSubagentTool(name) { + if isToolNameAllowed(allow, name) || isImplicitlyAllowedAgentTool(name) { filtered = append(filtered, td) } } @@ -2177,14 +2303,14 @@ func (al *AgentLoop) GetToolCatalog() []map[string]interface{} { name := strings.TrimSpace(fmt.Sprint(item["name"])) item["visibility"] = map[string]interface{}{ "main_agent": true, - "subagent": subagentToolVisibilityMode(name), + "agent": agentToolVisibilityMode(name), } } return items } -func subagentToolVisibilityMode(name string) string { - if isImplicitlyAllowedSubagentTool(name) { +func agentToolVisibilityMode(name string) string { + if isImplicitlyAllowedAgentTool(name) { return "inherited" } return "allowlist" @@ -2292,7 +2418,7 @@ func withToolRuntimeArgs(ctx context.Context, toolName string, args map[string]i } ns := normalizeMemoryNamespace(memoryNamespaceFromContext(ctx)) callerAgent := ns - callerScope := "subagent" + callerScope := "agent" if callerAgent == "" || callerAgent == "main" { callerAgent = "main" callerScope = "main_agent" @@ -2388,8 +2514,8 @@ func ensureToolAllowedByContext(ctx context.Context, toolName string, args map[s if name == "" { return fmt.Errorf("tool name is empty") } - if !isToolNameAllowed(allow, name) && !isImplicitlyAllowedSubagentTool(name) { - return fmt.Errorf("tool '%s' is not allowed by subagent profile", toolName) + if !isToolNameAllowed(allow, name) && !isImplicitlyAllowedAgentTool(name) { + return fmt.Errorf("tool '%s' is not allowed by agent profile", toolName) } if name == "parallel" { @@ -2415,21 +2541,20 @@ func validateParallelAllowlistArgs(allow map[string]struct{}, args map[string]in if name == "" { continue } - if !isToolNameAllowed(allow, name) && !isImplicitlyAllowedSubagentTool(name) { + if !isToolNameAllowed(allow, name) && !isImplicitlyAllowedAgentTool(name) { return fmt.Errorf("tool 'parallel' contains disallowed call[%d]: %s", i, tool) } } return nil } -func isImplicitlyAllowedSubagentTool(name string) bool { - _, ok := implicitSubagentToolSet[strings.ToLower(strings.TrimSpace(name))] +func isImplicitlyAllowedAgentTool(name string) bool { + _, ok := implicitAgentToolSet[strings.ToLower(strings.TrimSpace(name))] return ok } var toolContextEligibleSet = map[string]struct{}{ "message": {}, - "spawn": {}, "remind": {}, } @@ -2449,7 +2574,7 @@ func toolNeedsMemoryNamespace(name string) bool { return ok } -var implicitSubagentToolSet = map[string]struct{}{ +var implicitAgentToolSet = map[string]struct{}{ "skill_exec": {}, } @@ -2576,32 +2701,32 @@ func resolveSystemOrigin(chatID string) (string, string) { } } -func isSubagentSystemMessage(msg bus.InboundMessage) bool { +func isAgentSystemMessage(msg bus.InboundMessage) bool { if msg.Channel != "system" { return false } - if msg.Metadata != nil && strings.EqualFold(strings.TrimSpace(msg.Metadata["trigger"]), "subagent") { + if msg.Metadata != nil && strings.EqualFold(strings.TrimSpace(msg.Metadata["trigger"]), "agent") { return true } - return strings.HasPrefix(strings.ToLower(strings.TrimSpace(msg.SenderID)), "subagent:") + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(msg.SenderID)), "agent:") } -func fallbackSubagentNotification(msg bus.InboundMessage) (string, bool) { - if !isSubagentSystemMessage(msg) { +func fallbackAgentNotification(msg bus.InboundMessage) (string, bool) { + if !isAgentSystemMessage(msg) { return "", false } content := strings.TrimSpace(msg.Content) if content == "" { - id := strings.TrimSpace(strings.TrimPrefix(msg.SenderID, "subagent:")) + id := strings.TrimSpace(strings.TrimPrefix(msg.SenderID, "agent:")) if id == "" { id = "unknown" } - content = fmt.Sprintf("Subagent %s completed.", id) + content = fmt.Sprintf("Agent %s completed.", id) } return content, true } -func parseSubagentSystemContent(content string) (string, string) { +func parseAgentSystemContent(content string) (string, string) { content = strings.TrimSpace(content) if content == "" { return "", "" @@ -2641,55 +2766,55 @@ func summarizeSystemNotificationText(s string, max int) string { return s } -func (al *AgentLoop) enqueueSubagentDigest(msg bus.InboundMessage, item subagentDigestItem) { +func (al *AgentLoop) enqueueAgentDigest(msg bus.InboundMessage, item agentDigestItem) { if al == nil || al.bus == nil { return } originChannel, originChatID := resolveSystemOrigin(msg.ChatID) key := originChannel + "\x00" + originChatID - delay := al.subagentDigestDelay + delay := al.agentDigestDelay if delay <= 0 { delay = 5 * time.Second } - al.subagentDigestMu.Lock() - state, ok := al.subagentDigests[key] + al.agentDigestMu.Lock() + state, ok := al.agentDigests[key] if !ok || state == nil { - state = &subagentDigestState{ + state = &agentDigestState{ channel: originChannel, chatID: originChatID, - items: map[string]subagentDigestItem{}, + items: map[string]agentDigestItem{}, } - al.subagentDigests[key] = state + al.agentDigests[key] = state } - itemKey := subagentDigestItemKey(item) + itemKey := agentDigestItemKey(item) state.items[itemKey] = item state.dueAt = time.Now().Add(delay) - al.subagentDigestMu.Unlock() + al.agentDigestMu.Unlock() } -func subagentDigestItemKey(item subagentDigestItem) string { +func agentDigestItemKey(item agentDigestItem) string { agentID := strings.ToLower(strings.TrimSpace(item.agentID)) reason := strings.ToLower(strings.TrimSpace(item.reason)) task := strings.ToLower(strings.TrimSpace(item.taskSummary)) if agentID == "" { - agentID = "subagent" + agentID = "agent" } return agentID + "\x00" + reason + "\x00" + task } -func (al *AgentLoop) flushSubagentDigest(key string) { +func (al *AgentLoop) flushAgentDigest(key string) { if al == nil || al.bus == nil { return } - al.subagentDigestMu.Lock() - state := al.subagentDigests[key] - delete(al.subagentDigests, key) - al.subagentDigestMu.Unlock() + al.agentDigestMu.Lock() + state := al.agentDigests[key] + delete(al.agentDigests, key) + al.agentDigestMu.Unlock() if state == nil || len(state.items) == 0 { return } - content := formatSubagentDigestSummary(state.items) + content := formatAgentDigestSummary(state.items) if strings.TrimSpace(content) == "" { return } @@ -2700,7 +2825,7 @@ func (al *AgentLoop) flushSubagentDigest(key string) { }) } -func (al *AgentLoop) runSubagentDigestTicker() { +func (al *AgentLoop) runAgentDigestTicker() { if al == nil { return } @@ -2711,33 +2836,33 @@ func (al *AgentLoop) runSubagentDigestTicker() { ticker := time.NewTicker(tick) defer ticker.Stop() for now := range ticker.C { - al.flushDueSubagentDigests(now) + al.flushDueAgentDigests(now) } } -func (al *AgentLoop) flushDueSubagentDigests(now time.Time) { +func (al *AgentLoop) flushDueAgentDigests(now time.Time) { if al == nil || al.bus == nil { return } dueKeys := make([]string, 0, 4) - al.subagentDigestMu.Lock() - for key, state := range al.subagentDigests { + al.agentDigestMu.Lock() + for key, state := range al.agentDigests { if state == nil || state.dueAt.IsZero() || now.Before(state.dueAt) { continue } dueKeys = append(dueKeys, key) } - al.subagentDigestMu.Unlock() + al.agentDigestMu.Unlock() for _, key := range dueKeys { - al.flushSubagentDigest(key) + al.flushAgentDigest(key) } } -func formatSubagentDigestSummary(items map[string]subagentDigestItem) string { +func formatAgentDigestSummary(items map[string]agentDigestItem) string { if len(items) == 0 { return "" } - list := make([]subagentDigestItem, 0, len(items)) + list := make([]agentDigestItem, 0, len(items)) completed := 0 blocked := 0 milestone := 0 @@ -2785,7 +2910,7 @@ func formatSubagentDigestSummary(items map[string]subagentDigestItem) string { for _, item := range list { agentLabel := strings.TrimSpace(item.agentID) if agentLabel == "" { - agentLabel = "subagent" + agentLabel = "agent" } statusText := "已完æˆ" switch { diff --git a/pkg/agent/loop_allowlist_test.go b/pkg/agent/loop_allowlist_test.go index 673befc..259ad00 100644 --- a/pkg/agent/loop_allowlist_test.go +++ b/pkg/agent/loop_allowlist_test.go @@ -20,7 +20,7 @@ func TestEnsureToolAllowedByContext(t *testing.T) { t.Fatalf("expected disallowed tool to fail") } if err := ensureToolAllowedByContext(restricted, "skill_exec", map[string]interface{}{}); err != nil { - t.Fatalf("expected skill_exec to bypass subagent allowlist, got: %v", err) + t.Fatalf("expected skill_exec to bypass agent allowlist, got: %v", err) } } @@ -103,18 +103,18 @@ func TestWithToolRuntimeArgsForSkillExec(t *testing.T) { t.Fatalf("expected main agent runtime args, got: %#v", mainArgs) } - subagentCtx := withMemoryNamespaceContext(context.Background(), "coder") - subArgs := withToolRuntimeArgs(subagentCtx, "skill_exec", map[string]interface{}{"skill": "demo"}) - if subArgs["caller_agent"] != "coder" || subArgs["caller_scope"] != "subagent" { - t.Fatalf("expected subagent runtime args, got: %#v", subArgs) + agentCtx := withMemoryNamespaceContext(context.Background(), "coder") + agentArgs := withToolRuntimeArgs(agentCtx, "skill_exec", map[string]interface{}{"skill": "demo"}) + if agentArgs["caller_agent"] != "coder" || agentArgs["caller_scope"] != "agent" { + t.Fatalf("expected agent runtime args, got: %#v", agentArgs) } } -func TestSubagentToolVisibilityMode(t *testing.T) { - if got := subagentToolVisibilityMode("skill_exec"); got != "inherited" { +func TestAgentToolVisibilityMode(t *testing.T) { + if got := agentToolVisibilityMode("skill_exec"); got != "inherited" { t.Fatalf("expected skill_exec inherited, got %q", got) } - if got := subagentToolVisibilityMode("write_file"); got != "allowlist" { + if got := agentToolVisibilityMode("write_file"); got != "allowlist" { t.Fatalf("expected write_file allowlist, got %q", got) } } diff --git a/pkg/agent/loop_nodes_p2p_test.go b/pkg/agent/loop_nodes_p2p_test.go index 2d11472..ca15c3b 100644 --- a/pkg/agent/loop_nodes_p2p_test.go +++ b/pkg/agent/loop_nodes_p2p_test.go @@ -13,7 +13,7 @@ func TestNewAgentLoopDisablesNodeP2PByDefault(t *testing.T) { loop := NewAgentLoop(cfg, bus.NewMessageBus(), stubLLMProvider{}, nil) if loop.nodeRouter == nil { - t.Fatalf("expected node router to be configured") + t.Fatalf("expected node transport to be configured") } if loop.nodeRouter.P2P != nil { t.Fatalf("expected node p2p transport to be disabled by default") diff --git a/pkg/agent/loop_system_notify_test.go b/pkg/agent/loop_system_notify_test.go index 4004c9b..3a4e683 100644 --- a/pkg/agent/loop_system_notify_test.go +++ b/pkg/agent/loop_system_notify_test.go @@ -9,21 +9,21 @@ import ( "github.com/YspCoder/clawgo/pkg/bus" ) -func TestPrepareOutboundSubagentNoReplyFallback(t *testing.T) { +func TestPrepareOutboundAgentNoReplyFallback(t *testing.T) { al := &AgentLoop{} msg := bus.InboundMessage{ Channel: "system", - SenderID: "subagent:subagent-1", + SenderID: "agent:agent-1", ChatID: "telegram:9527", Content: "Task 'coder' completed.\n\nResult:\nOK", Metadata: map[string]string{ - "trigger": "subagent", + "trigger": "agent", }, } outbound, ok := al.prepareOutbound(msg, "NO_REPLY") if !ok { - t.Fatalf("expected outbound notification for subagent NO_REPLY fallback") + t.Fatalf("expected outbound notification for agent NO_REPLY fallback") } if outbound.Channel != "telegram" || outbound.ChatID != "9527" { t.Fatalf("unexpected outbound target: %s:%s", outbound.Channel, outbound.ChatID) @@ -33,7 +33,7 @@ func TestPrepareOutboundSubagentNoReplyFallback(t *testing.T) { } } -func TestPrepareOutboundNoReplySuppressedForNonSubagent(t *testing.T) { +func TestPrepareOutboundNoReplySuppressedForNonAgent(t *testing.T) { al := &AgentLoop{} msg := bus.InboundMessage{ Channel: "cli", @@ -42,18 +42,18 @@ func TestPrepareOutboundNoReplySuppressedForNonSubagent(t *testing.T) { } if _, ok := al.prepareOutbound(msg, "NO_REPLY"); ok { - t.Fatalf("expected NO_REPLY to be suppressed for non-subagent messages") + t.Fatalf("expected NO_REPLY to be suppressed for non-agent messages") } } -func TestPrepareOutboundSubagentNoReplyFallbackWithMissingOrigin(t *testing.T) { +func TestPrepareOutboundAgentNoReplyFallbackWithMissingOrigin(t *testing.T) { al := &AgentLoop{} msg := bus.InboundMessage{ Channel: "system", - SenderID: "subagent:subagent-9", + SenderID: "agent:agent-9", ChatID: ":", Metadata: map[string]string{ - "trigger": "subagent", + "trigger": "agent", }, } @@ -64,25 +64,25 @@ func TestPrepareOutboundSubagentNoReplyFallbackWithMissingOrigin(t *testing.T) { if outbound.Channel != "cli" || outbound.ChatID != "direct" { t.Fatalf("expected fallback origin cli:direct, got %s:%s", outbound.Channel, outbound.ChatID) } - if outbound.Content != "Subagent subagent-9 completed." { + if outbound.Content != "Agent agent-9 completed." { t.Fatalf("unexpected fallback content: %q", outbound.Content) } } -func TestProcessSystemMessageSubagentBlockedQueuedIntoDigest(t *testing.T) { +func TestProcessSystemMessageAgentBlockedQueuedIntoDigest(t *testing.T) { msgBus := bus.NewMessageBus() al := &AgentLoop{ bus: msgBus, - subagentDigestDelay: 10 * time.Millisecond, - subagentDigests: map[string]*subagentDigestState{}, + agentDigestDelay: 10 * time.Millisecond, + agentDigests: map[string]*agentDigestState{}, } out, err := al.processSystemMessage(context.Background(), bus.InboundMessage{ Channel: "system", - SenderID: "subagent:subagent-3", + SenderID: "agent:agent-3", ChatID: "telegram:9527", - Content: "Subagent update\nagent: coder\nrun: subagent-3\nstatus: blocked\nreason: blocked\ntask: ä¿®å¤ç™»å½•\nsummary: rate limit", + Content: "Agent update\nagent: coder\nrun: agent-3\nstatus: blocked\nreason: blocked\ntask: ä¿®å¤ç™»å½•\nsummary: rate limit", Metadata: map[string]string{ - "trigger": "subagent", + "trigger": "agent", "agent_id": "coder", "status": "failed", "notify_reason": "blocked", @@ -94,7 +94,7 @@ func TestProcessSystemMessageSubagentBlockedQueuedIntoDigest(t *testing.T) { if out != "" { t.Fatalf("expected queued digest with no immediate output, got %q", out) } - al.flushDueSubagentDigests(time.Now().Add(20 * time.Millisecond)) + al.flushDueAgentDigests(time.Now().Add(20 * time.Millisecond)) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() outbound, ok := msgBus.SubscribeOutbound(ctx) @@ -106,20 +106,20 @@ func TestProcessSystemMessageSubagentBlockedQueuedIntoDigest(t *testing.T) { } } -func TestProcessSystemMessageSubagentDigestMergesMultipleUpdates(t *testing.T) { +func TestProcessSystemMessageAgentDigestMergesMultipleUpdates(t *testing.T) { msgBus := bus.NewMessageBus() al := &AgentLoop{ bus: msgBus, - subagentDigestDelay: 10 * time.Millisecond, - subagentDigests: map[string]*subagentDigestState{}, + agentDigestDelay: 10 * time.Millisecond, + agentDigests: map[string]*agentDigestState{}, } first := bus.InboundMessage{ Channel: "system", - SenderID: "subagent:subagent-7", + SenderID: "agent:agent-7", ChatID: "telegram:9527", - Content: "Subagent update\nagent: tester\nrun: subagent-7\nstatus: completed\nreason: final\ntask: 回归测试\nsummary: 所有测试通过", + Content: "Agent update\nagent: tester\nrun: agent-7\nstatus: completed\nreason: final\ntask: 回归测试\nsummary: 所有测试通过", Metadata: map[string]string{ - "trigger": "subagent", + "trigger": "agent", "agent_id": "tester", "status": "completed", "notify_reason": "final", @@ -127,11 +127,11 @@ func TestProcessSystemMessageSubagentDigestMergesMultipleUpdates(t *testing.T) { } second := bus.InboundMessage{ Channel: "system", - SenderID: "subagent:subagent-8", + SenderID: "agent:agent-8", ChatID: "telegram:9527", - Content: "Subagent update\nagent: coder\nrun: subagent-8\nstatus: completed\nreason: final\ntask: ä¿®å¤ç™»å½•\nsummary: 接å£å·²è”è°ƒ", + Content: "Agent update\nagent: coder\nrun: agent-8\nstatus: completed\nreason: final\ntask: ä¿®å¤ç™»å½•\nsummary: 接å£å·²è”è°ƒ", Metadata: map[string]string{ - "trigger": "subagent", + "trigger": "agent", "agent_id": "coder", "status": "completed", "notify_reason": "final", @@ -143,7 +143,7 @@ func TestProcessSystemMessageSubagentDigestMergesMultipleUpdates(t *testing.T) { if out, err := al.processSystemMessage(context.Background(), second); err != nil || out != "" { t.Fatalf("unexpected second result out=%q err=%v", out, err) } - al.flushDueSubagentDigests(time.Now().Add(20 * time.Millisecond)) + al.flushDueAgentDigests(time.Now().Add(20 * time.Millisecond)) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() outbound, ok := msgBus.SubscribeOutbound(ctx) diff --git a/pkg/agent/memory_log_test.go b/pkg/agent/memory_log_test.go index 2073aae..8525207 100644 --- a/pkg/agent/memory_log_test.go +++ b/pkg/agent/memory_log_test.go @@ -12,13 +12,13 @@ import ( "github.com/YspCoder/clawgo/pkg/runtimecfg" ) -func TestAppendDailySummaryLogUsesSubagentNamespaceAndTitle(t *testing.T) { +func TestAppendDailySummaryLogUsesAgentNamespaceAndTitle(t *testing.T) { workspace := t.TempDir() cfg := config.DefaultConfig() - cfg.Agents.Subagents["coder"] = config.SubagentConfig{ - Enabled: true, - DisplayName: "Code Agent", - SystemPromptFile: "agents/coder/AGENT.md", + cfg.Agents.Agents["coder"] = config.AgentConfig{ + Enabled: true, + DisplayName: "Code Agent", + PromptFile: "agents/coder/AGENT.md", } runtimecfg.Set(cfg) t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) @@ -26,7 +26,7 @@ func TestAppendDailySummaryLogUsesSubagentNamespaceAndTitle(t *testing.T) { loop := &AgentLoop{workspace: workspace} loop.appendDailySummaryLog(bus.InboundMessage{ Channel: "cli", - SessionKey: "subagent:coder:subagent-1", + SessionKey: "agent:coder:agent-1", Content: "Role Profile Policy (agents/coder/AGENT.md):\n...\n\nTask:\nä¿®å¤ç™»å½•接å£å¹¶è¡¥æµ‹è¯•\nextra details", Metadata: map[string]string{ "memory_ns": "coder", @@ -51,10 +51,10 @@ func TestAppendDailySummaryLogUsesSubagentNamespaceAndTitle(t *testing.T) { } mainContent := string(mainEntries) if !strings.Contains(mainContent, "Code Agent | ä¿®å¤ç™»å½•接å£å¹¶è¡¥æµ‹è¯•") { - t.Fatalf("expected main memory to include subagent title, got %s", mainContent) + t.Fatalf("expected main memory to include agent title, got %s", mainContent) } - if !strings.Contains(mainContent, "- Subagent: Code Agent") { - t.Fatalf("expected main memory to include subagent name, got %s", mainContent) + if !strings.Contains(mainContent, "- Agent: Code Agent") { + t.Fatalf("expected main memory to include agent name, got %s", mainContent) } if !strings.Contains(mainContent, "- Did: 完æˆäº†ç™»å½•接å£ä¿®å¤ã€å¢žåŠ å›žå½’æµ‹è¯•ï¼Œå¹¶éªŒè¯é€šè¿‡ã€‚") { t.Fatalf("expected main memory to include summary, got %s", mainContent) diff --git a/pkg/agent/router_dispatch.go b/pkg/agent/router_dispatch.go deleted file mode 100644 index c6c8740..0000000 --- a/pkg/agent/router_dispatch.go +++ /dev/null @@ -1,249 +0,0 @@ -package agent - -import ( - "context" - "strings" - "time" - - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/runtimecfg" - "github.com/YspCoder/clawgo/pkg/tools" -) - -func (al *AgentLoop) maybeAutoRoute(ctx context.Context, msg bus.InboundMessage) (string, bool, error) { - if al == nil || al.subagentRouter == nil { - return "", false, nil - } - if msg.Channel == "system" || msg.Channel == "internal" { - return "", false, nil - } - if msg.Metadata != nil { - if trigger := strings.ToLower(strings.TrimSpace(msg.Metadata["trigger"])); trigger != "" && trigger != "user" { - return "", false, nil - } - } - cfg := runtimecfg.Get() - if cfg == nil || !cfg.Agents.Router.Enabled { - return "", false, nil - } - decision := resolveDispatchDecision(cfg, msg.Content) - if !decision.Valid() { - return "", false, nil - } - waitTimeout := cfg.Agents.Router.DefaultTimeoutSec - if waitTimeout <= 0 { - waitTimeout = 120 - } - waitCtx, cancel := context.WithTimeout(ctx, time.Duration(waitTimeout)*time.Second) - defer cancel() - task, err := al.subagentRouter.DispatchTask(waitCtx, tools.RouterDispatchRequest{ - Task: decision.TaskText, - AgentID: decision.TargetAgent, - Decision: &decision, - NotifyMainPolicy: "internal_only", - OriginChannel: msg.Channel, - OriginChatID: msg.ChatID, - }) - if err != nil { - return "", true, err - } - reply, err := al.subagentRouter.WaitReply(waitCtx, task.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 resolveDispatchDecision(cfg *config.Config, raw string) tools.DispatchDecision { - if cfg == nil { - return tools.DispatchDecision{} - } - content := strings.TrimSpace(raw) - if content == "" || len(cfg.Agents.Subagents) == 0 { - return tools.DispatchDecision{} - } - maxChars := cfg.Agents.Router.Policy.IntentMaxInputChars - if maxChars > 0 && len([]rune(content)) > maxChars { - return tools.DispatchDecision{} - } - lower := strings.ToLower(content) - for agentID, subcfg := range cfg.Agents.Subagents { - if !subcfg.Enabled { - continue - } - marker := "@" + strings.ToLower(strings.TrimSpace(agentID)) - if strings.HasPrefix(lower, marker+" ") || lower == marker { - return tools.DispatchDecision{ - TargetAgent: agentID, - Reason: "explicit agent mention", - Confidence: 1, - TaskText: strings.TrimSpace(content[len(marker):]), - RouteSource: "explicit", - } - } - prefix := "agent:" + strings.ToLower(strings.TrimSpace(agentID)) - if strings.HasPrefix(lower, prefix+" ") || lower == prefix { - return tools.DispatchDecision{ - TargetAgent: agentID, - Reason: "explicit agent prefix", - Confidence: 1, - TaskText: strings.TrimSpace(content[len(prefix):]), - RouteSource: "explicit", - } - } - } - if strings.EqualFold(strings.TrimSpace(cfg.Agents.Router.Strategy), "rules_first") { - if agentID := selectAgentByRules(cfg, content); agentID != "" { - return tools.DispatchDecision{ - TargetAgent: agentID, - Reason: "matched router rules or role keywords", - Confidence: 0.8, - TaskText: content, - RouteSource: "rules", - } - } - } - return tools.DispatchDecision{} -} - -func selectAgentByRules(cfg *config.Config, content string) string { - if cfg == nil { - return "" - } - content = strings.TrimSpace(content) - if content == "" { - return "" - } - lower := strings.ToLower(content) - bestID := "" - bestScore := 0 - tied := false - if agentID := selectAgentByConfiguredRules(cfg, lower); agentID != "" { - return agentID - } - for agentID, subcfg := range cfg.Agents.Subagents { - if !subcfg.Enabled { - continue - } - score := scoreRouteCandidate(agentID, subcfg, lower) - if score <= 0 { - continue - } - if score > bestScore { - bestID = agentID - bestScore = score - tied = false - continue - } - if score == bestScore { - tied = true - } - } - if tied || bestScore < 2 { - return "" - } - return bestID -} - -func selectAgentByConfiguredRules(cfg *config.Config, content string) string { - if cfg == nil { - return "" - } - bestID := "" - bestScore := 0 - tied := false - for _, rule := range cfg.Agents.Router.Rules { - agentID := strings.TrimSpace(rule.AgentID) - if agentID == "" { - continue - } - subcfg, ok := cfg.Agents.Subagents[agentID] - if !ok || !subcfg.Enabled { - continue - } - score := 0 - for _, kw := range rule.Keywords { - kw = strings.ToLower(strings.TrimSpace(kw)) - if kw != "" && strings.Contains(content, kw) { - score++ - } - } - if score <= 0 { - continue - } - if score > bestScore { - bestID = agentID - bestScore = score - tied = false - continue - } - if score == bestScore { - tied = true - } - } - if tied || bestScore < 1 { - return "" - } - return bestID -} - -func scoreRouteCandidate(agentID string, subcfg config.SubagentConfig, content string) int { - score := 0 - for _, token := range routeKeywords(agentID, subcfg) { - token = strings.ToLower(strings.TrimSpace(token)) - if token == "" { - continue - } - if strings.Contains(content, token) { - score++ - } - } - return score -} - -func routeKeywords(agentID string, subcfg config.SubagentConfig) []string { - set := map[string]struct{}{} - add := func(items ...string) { - for _, item := range items { - item = strings.ToLower(strings.TrimSpace(item)) - if item == "" { - continue - } - set[item] = struct{}{} - } - } - add(agentID, subcfg.Role, subcfg.DisplayName, subcfg.Type) - role := strings.ToLower(strings.TrimSpace(subcfg.Role)) - switch role { - case "code", "coding", "coder", "dev", "developer": - add("code", "coding", "implement", "refactor", "fix bug", "bugfix", "debug", "写代ç ", "实现", "釿ž„", "ä¿®å¤", "改代ç ") - case "test", "tester", "testing", "qa": - add("test", "testing", "regression", "verify", "validate", "qa", "回归", "测试", "验è¯", "检查") - case "docs", "doc", "writer", "documentation": - add("docs", "documentation", "write docs", "document", "readme", "文档", "说明", "README") - case "research", "researcher": - add("research", "investigate", "analyze", "compare", "调研", "分æž", "研究", "比较") - } - agentLower := strings.ToLower(strings.TrimSpace(agentID)) - switch agentLower { - case "coder": - add("code", "implement", "fix", "debug", "写代ç ", "实现", "ä¿®å¤") - case "tester": - add("test", "regression", "verify", "回归", "测试", "验è¯") - case "researcher": - add("research", "analyze", "investigate", "调研", "分æž") - case "doc_writer", "writer", "docs": - add("docs", "readme", "document", "文档", "说明") - } - out := make([]string, 0, len(set)) - for item := range set { - out = append(out, item) - } - return out -} diff --git a/pkg/agent/router_dispatch_test.go b/pkg/agent/router_dispatch_test.go deleted file mode 100644 index 0b3b851..0000000 --- a/pkg/agent/router_dispatch_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package agent - -import ( - "context" - "testing" - - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/runtimecfg" - "github.com/YspCoder/clawgo/pkg/tools" -) - -func TestResolveAutoRouteTarget(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) - } -} - -func TestResolveAutoRouteTargetRulesFirst(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Router.Strategy = "rules_first" - cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true, Role: "coding", SystemPromptFile: "agents/coder/AGENT.md"} - 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) - } -} - -func TestMaybeAutoRouteDispatchesExplicitAgentMention(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Router.DefaultTimeoutSec = 5 - cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true, SystemPromptFile: "agents/coder/AGENT.md"} - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - workspace := t.TempDir() - manager := tools.NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) { - return "auto-routed", nil - }) - loop := &AgentLoop{ - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - out, ok, err := loop.maybeAutoRoute(context.Background(), bus.InboundMessage{ - Channel: "cli", - ChatID: "direct", - SessionKey: "main", - Content: "@coder implement auth", - }) - if err != nil { - t.Fatalf("auto route failed: %v", err) - } - if !ok { - t.Fatalf("expected auto route to trigger") - } - if out == "" { - t.Fatalf("expected merged output") - } -} - -func TestMaybeAutoRouteSkipsNormalMessages(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true, SystemPromptFile: "agents/coder/AGENT.md"} - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - loop := &AgentLoop{} - out, ok, err := loop.maybeAutoRoute(context.Background(), bus.InboundMessage{ - Channel: "cli", - ChatID: "direct", - SessionKey: "main", - Content: "please help with auth", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ok || out != "" { - t.Fatalf("expected normal message to skip auto route, got ok=%v out=%q", ok, out) - } -} - -func TestMaybeAutoRouteDispatchesRulesFirstMatch(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Router.Strategy = "rules_first" - cfg.Agents.Router.DefaultTimeoutSec = 5 - cfg.Agents.Subagents["tester"] = config.SubagentConfig{Enabled: true, Role: "testing", SystemPromptFile: "agents/tester/AGENT.md"} - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - workspace := t.TempDir() - manager := tools.NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) { - return "tested", nil - }) - loop := &AgentLoop{ - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - out, ok, err := loop.maybeAutoRoute(context.Background(), bus.InboundMessage{ - Channel: "cli", - ChatID: "direct", - SessionKey: "main", - Content: "please run regression testing and verify this fix", - }) - if err != nil { - t.Fatalf("rules-first auto route failed: %v", err) - } - if !ok { - t.Fatalf("expected rules-first auto route to trigger") - } - if out == "" { - t.Fatalf("expected merged output") - } -} - -func TestResolveDispatchDecisionIncludesReason(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Router.Strategy = "rules_first" - cfg.Agents.Subagents["tester"] = config.SubagentConfig{Enabled: true, Role: "testing", SystemPromptFile: "agents/tester/AGENT.md"} - - decision := resolveDispatchDecision(cfg, "run regression testing for this change") - if !decision.Valid() { - t.Fatalf("expected valid decision") - } - if decision.TargetAgent != "tester" || decision.RouteSource == "" || decision.Reason == "" { - t.Fatalf("unexpected decision: %+v", decision) - } -} - -func TestResolveAutoRouteTargetSkipsOversizedIntent(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) - } -} diff --git a/pkg/agent/runtime_admin.go b/pkg/agent/runtime_admin.go index 739fc6e..7dadc22 100644 --- a/pkg/agent/runtime_admin.go +++ b/pkg/agent/runtime_admin.go @@ -3,39 +3,26 @@ 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" ) -var subagentRuntimeActionAliases = map[string]string{ - "info": "get", - "create": "spawn", - "trace": "thread", +var runtimeAdminActionAliases = map[string]string{ } -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") +func (al *AgentLoop) HandleRuntimeAdmin(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) { + if al == nil || al.agentManager == nil { + return nil, fmt.Errorf("runtime admin is not configured") } action = strings.ToLower(strings.TrimSpace(action)) if action == "" { - action = "list" + action = "snapshot" } - if canonical := subagentRuntimeActionAliases[action]; canonical != "" { + if canonical := runtimeAdminActionAliases[action]; canonical != "" { action = canonical } - handler := al.subagentRuntimeHandlers()[action] + handler := al.runtimeAdminHandlers()[action] if handler == nil { return nil, fmt.Errorf("unsupported action: %s", action) } @@ -44,566 +31,140 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a type runtimeAdminHandler func(context.Context, map[string]interface{}) (interface{}, error) -func (al *AgentLoop) subagentRuntimeHandlers() map[string]runtimeAdminHandler { - sm := al.subagentManager - router := al.subagentRouter +func (al *AgentLoop) runtimeAdminHandlers() map[string]runtimeAdminHandler { + sm := al.agentManager return map[string]runtimeAdminHandler{ - "list": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - 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 - }, "snapshot": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { limit := runtimeIntArg(args, "limit", 100) - return map[string]interface{}{"snapshot": sm.RuntimeSnapshot(limit)}, nil - }, - "get": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - 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 - }, - "spawn": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - 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 - }, - "dispatch_and_wait": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - 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 - }, - "registry": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - 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", - }) + snapshot := sm.RuntimeSnapshot(limit) + if al.worldRuntime != nil && al.worldRuntime.Enabled() { + if worldSnap, err := al.worldRuntime.Snapshot(limit); err == nil { + snapshot.World = worldSnap } } - 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, - }) - } - } + return map[string]interface{}{"snapshot": snapshot}, nil + }, + "world_snapshot": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + out, err := al.worldRuntime.Snapshot(runtimeIntArg(args, "limit", 20)) + if err != nil { + return nil, err + } + return map[string]interface{}{"snapshot": out}, nil + }, + "world_tick": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + out, err := al.worldRuntime.Tick(ctx, fallbackString(runtimeStringArg(args, "source"), "runtime_admin")) + if err != nil { + return nil, err + } + return map[string]interface{}{"message": out}, nil + }, + "world_npc_list": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + items, err := al.worldRuntime.NPCList() + if err != nil { + return nil, err } - 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 }, - "set_config_subagent_enabled": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - agentID := runtimeStringArg(args, "agent_id") - if agentID == "" { - return nil, fmt.Errorf("agent_id is required") + "world_npc_get": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") } - 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, - }) - }, - "delete_config_subagent": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - 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) - }, - "upsert_config_subagent": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - return tools.UpsertConfigSubagent(al.configPath, args) - }, - "prompt_file_get": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - relPath := runtimeStringArg(args, "path") - if relPath == "" { - return nil, fmt.Errorf("path is required") - } - absPath, err := al.resolvePromptFilePath(relPath) + item, found, err := al.worldRuntime.NPCGet(runtimeStringArg(args, "id")) 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 + return map[string]interface{}{"found": found, "item": item}, nil }, - "prompt_file_set": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - relPath := runtimeStringArg(args, "path") - if relPath == "" { - return nil, fmt.Errorf("path is required") + "world_entity_list": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") } - content := runtimeRawStringArg(args, "content") - absPath, err := al.resolvePromptFilePath(relPath) + items, err := al.worldRuntime.EntityList() 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 + return map[string]interface{}{"items": items}, nil }, - "prompt_file_bootstrap": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - agentID := runtimeStringArg(args, "agent_id") - if agentID == "" { - return nil, fmt.Errorf("agent_id is required") + "world_entity_get": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") } - relPath := runtimeStringArg(args, "path") - if relPath == "" { - relPath = filepath.ToSlash(filepath.Join("agents", agentID, "AGENT.md")) - } - absPath, err := al.resolvePromptFilePath(relPath) + item, found, err := al.worldRuntime.EntityGet(runtimeStringArg(args, "id")) 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 + return map[string]interface{}{"found": found, "item": item}, nil }, - "kill": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) + "world_get": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + item, err := al.worldRuntime.WorldGet() if err != nil { return nil, err } - ok := sm.KillTask(taskID) - return map[string]interface{}{"ok": ok}, nil + return item, nil }, - "resume": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) + "world_event_log": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + items, err := al.worldRuntime.EventLog(runtimeIntArg(args, "limit", 20)) if err != nil { return nil, err } - label, ok := sm.ResumeTask(ctx, taskID) - return map[string]interface{}{"ok": ok, "label": label}, nil + return map[string]interface{}{"items": items}, nil }, - "steer": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) - if err != nil { - return nil, err + "world_npc_create": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") } - 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 + return al.worldRuntime.CreateNPC(ctx, args) }, - "send": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) - if err != nil { - return nil, err + "world_entity_create": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") } - 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 + return al.worldRuntime.CreateEntity(ctx, args) }, - "reply": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) + "world_quest_list": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + items, err := al.worldRuntime.QuestList() 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 + return map[string]interface{}{"items": items}, nil }, - "ack": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) + "world_quest_get": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + item, found, err := al.worldRuntime.QuestGet(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 + return map[string]interface{}{"found": found, "item": item}, nil }, - "thread": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - 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) + "world_quest_create": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") } - 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 - }, - "stream": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - 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 - }, - "inbox": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - 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 + return al.worldRuntime.CreateQuest(ctx, args) }, } } -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) } @@ -626,84 +187,3 @@ func fallbackString(v, fallback string) string { } 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_config_test.go b/pkg/agent/runtime_admin_config_test.go deleted file mode 100644 index 256b966..0000000 --- a/pkg/agent/runtime_admin_config_test.go +++ /dev/null @@ -1,347 +0,0 @@ -package agent - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/runtimecfg" - "github.com/YspCoder/clawgo/pkg/tools" -) - -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") - } -} diff --git a/pkg/agent/runtime_admin_test.go b/pkg/agent/runtime_admin_test.go deleted file mode 100644 index dd159cf..0000000 --- a/pkg/agent/runtime_admin_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package agent - -import ( - "context" - "testing" - "time" - - "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 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) - } - if _, ok := out.(map[string]interface{}); !ok { - t.Fatalf("unexpected spawn payload: %T", out) - } - 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) - } -} diff --git a/pkg/agent/session_planner_progress_test.go b/pkg/agent/session_planner_progress_test.go index 41bd2e1..c1c83d9 100644 --- a/pkg/agent/session_planner_progress_test.go +++ b/pkg/agent/session_planner_progress_test.go @@ -41,7 +41,7 @@ func TestShouldPublishPlannedTaskProgress(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) cancel() - if shouldPublishPlannedTaskProgress(ctx, 12, 5, plannedTaskResult{Err: errors.New("worker exited after parent stop"), ErrText: "worker exited after parent stop"}, milestones, notified) { + if shouldPublishPlannedTaskProgress(ctx, 12, 5, plannedTaskResult{Err: errors.New("agent exited after parent stop"), ErrText: "agent exited after parent stop"}, milestones, notified) { t.Fatalf("did not expect notification after parent cancellation") } } @@ -54,7 +54,7 @@ func TestIsPlannedTaskCancellation(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) cancel() - if !isPlannedTaskCancellation(ctx, plannedTaskResult{Err: errors.New("worker exited after parent stop"), ErrText: "worker exited after parent stop"}) { + if !isPlannedTaskCancellation(ctx, plannedTaskResult{Err: errors.New("agent exited after parent stop"), ErrText: "agent exited after parent stop"}) { t.Fatalf("expected canceled parent context to suppress planned task result") } if isPlannedTaskCancellation(context.Background(), plannedTaskResult{Err: errors.New("boom"), ErrText: "boom"}) { diff --git a/pkg/agent/session_planner_test.go b/pkg/agent/session_planner_test.go index d675513..7960353 100644 --- a/pkg/agent/session_planner_test.go +++ b/pkg/agent/session_planner_test.go @@ -13,16 +13,16 @@ import ( func TestSummarizePlannedTaskProgressBodyPreservesUsefulLines(t *testing.T) { t.Parallel() - body := "subagent 已写入 config.json。\npath: /root/.clawgo/config.json\nagent_id: tester" + body := "agent 已写入 config.json。\npath: /root/.clawgo/config.json\nagent_id: tester" out := summarizePlannedTaskProgressBody(body, 6, 320) - if !strings.Contains(out, "subagent 已写入 config.json。") { + if !strings.Contains(out, "agent 已写入 config.json。") { t.Fatalf("expected title line, got:\n%s", out) } if !strings.Contains(out, "agent_id: tester") { t.Fatalf("expected agent id line, got:\n%s", out) } - if strings.Contains(out, "subagent 已写入 config.json。 path:") { + if strings.Contains(out, "agent 已写入 config.json。 path:") { t.Fatalf("expected multi-line formatting, got:\n%s", out) } } diff --git a/pkg/agent/world_runtime.go b/pkg/agent/world_runtime.go new file mode 100644 index 0000000..f8276c2 --- /dev/null +++ b/pkg/agent/world_runtime.go @@ -0,0 +1,898 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/YspCoder/clawgo/pkg/tools" + "github.com/YspCoder/clawgo/pkg/world" +) + +type WorldRuntime struct { + store *world.Store + engine *world.Engine + profiles *tools.AgentProfileStore + dispatcher *tools.AgentDispatcher + manager *tools.AgentManager + maxCatchUp int + maxNPCPerTick int +} + +func NewWorldRuntime(workspace string, profiles *tools.AgentProfileStore, dispatcher *tools.AgentDispatcher, manager *tools.AgentManager) *WorldRuntime { + return &WorldRuntime{ + store: world.NewStore(workspace), + engine: world.NewEngine(), + profiles: profiles, + dispatcher: dispatcher, + manager: manager, + maxCatchUp: 3, + maxNPCPerTick: 8, + } +} + +func (wr *WorldRuntime) Enabled() bool { + if wr == nil { + return false + } + profiles, err := wr.worldProfiles() + return err == nil && len(profiles) > 0 +} + +func (wr *WorldRuntime) Snapshot(limit int) (interface{}, error) { + state, npcStates, err := wr.ensureState() + if err != nil { + return nil, err + } + events, err := wr.store.Events(limit) + if err != nil { + return nil, err + } + return wr.engine.Snapshot(state, npcStates, events, 0, limit), nil +} + +func (wr *WorldRuntime) Tick(ctx context.Context, source string) (string, error) { + res, err := wr.advance(ctx, world.WorldTickRequest{Source: source}) + if err != nil { + return "", err + } + return res.Text, nil +} + +func (wr *WorldRuntime) NPCList() ([]map[string]interface{}, error) { + _, npcStates, err := wr.ensureState() + if err != nil { + return nil, err + } + profiles, err := wr.worldProfiles() + if err != nil { + return nil, err + } + out := make([]map[string]interface{}, 0, len(profiles)) + for _, profile := range profiles { + state := npcStates[profile.AgentID] + out = append(out, map[string]interface{}{ + "npc_id": profile.AgentID, + "display_name": profile.Name, + "persona": profile.Persona, + "home_location": profile.HomeLocation, + "current_location": state.CurrentLocation, + "default_goals": append([]string(nil), profile.DefaultGoals...), + "status": state.Status, + "kind": profile.Kind, + "perception_scope": profile.PerceptionScope, + }) + } + sort.Slice(out, func(i, j int) bool { + return fmt.Sprint(out[i]["npc_id"]) < fmt.Sprint(out[j]["npc_id"]) + }) + return out, nil +} + +func (wr *WorldRuntime) NPCGet(id string) (map[string]interface{}, bool, error) { + _, npcStates, err := wr.ensureState() + if err != nil { + return nil, false, err + } + profile, ok, err := wr.profiles.Get(id) + if err != nil || !ok { + return nil, false, err + } + state, ok := npcStates[profile.AgentID] + if !ok { + return nil, false, nil + } + return map[string]interface{}{ + "profile": profile, + "state": state, + }, true, nil +} + +func (wr *WorldRuntime) EntityList() ([]map[string]interface{}, error) { + state, _, err := wr.ensureState() + if err != nil { + return nil, err + } + out := make([]map[string]interface{}, 0, len(state.Entities)) + for _, entity := range state.Entities { + out = append(out, map[string]interface{}{ + "id": entity.ID, + "name": entity.Name, + "type": entity.Type, + "location_id": entity.LocationID, + "state": entity.State, + }) + } + sort.Slice(out, func(i, j int) bool { return fmt.Sprint(out[i]["id"]) < fmt.Sprint(out[j]["id"]) }) + return out, nil +} + +func (wr *WorldRuntime) EntityGet(id string) (map[string]interface{}, bool, error) { + state, _, err := wr.ensureState() + if err != nil { + return nil, false, err + } + entity, ok := state.Entities[strings.TrimSpace(id)] + if !ok { + return nil, false, nil + } + return map[string]interface{}{ + "id": entity.ID, + "name": entity.Name, + "type": entity.Type, + "location_id": entity.LocationID, + "state": entity.State, + }, true, nil +} + +func (wr *WorldRuntime) WorldGet() (map[string]interface{}, error) { + state, _, err := wr.ensureState() + if err != nil { + return nil, err + } + return map[string]interface{}{ + "world_state": state, + }, nil +} + +func (wr *WorldRuntime) EventLog(limit int) ([]map[string]interface{}, error) { + events, err := wr.store.Events(limit) + if err != nil { + return nil, err + } + out := make([]map[string]interface{}, 0, len(events)) + for _, evt := range events { + out = append(out, map[string]interface{}{ + "id": evt.ID, + "type": evt.Type, + "actor_id": evt.ActorID, + "location_id": evt.LocationID, + "content": evt.Content, + "tick": evt.Tick, + "created_at": evt.CreatedAt, + }) + } + return out, nil +} + +func (wr *WorldRuntime) QuestList() ([]map[string]interface{}, error) { + state, _, err := wr.ensureState() + if err != nil { + return nil, err + } + out := make([]map[string]interface{}, 0, len(state.ActiveQuests)) + for _, quest := range state.ActiveQuests { + out = append(out, map[string]interface{}{ + "id": quest.ID, + "title": quest.Title, + "status": quest.Status, + "owner_npc_id": quest.OwnerNPCID, + "participants": append([]string(nil), quest.Participants...), + "summary": quest.Summary, + }) + } + sort.Slice(out, func(i, j int) bool { return fmt.Sprint(out[i]["id"]) < fmt.Sprint(out[j]["id"]) }) + return out, nil +} + +func (wr *WorldRuntime) QuestGet(id string) (map[string]interface{}, bool, error) { + state, _, err := wr.ensureState() + if err != nil { + return nil, false, err + } + quest, ok := state.ActiveQuests[strings.TrimSpace(id)] + if !ok { + return nil, false, nil + } + return map[string]interface{}{ + "id": quest.ID, + "title": quest.Title, + "status": quest.Status, + "owner_npc_id": quest.OwnerNPCID, + "participants": append([]string(nil), quest.Participants...), + "summary": quest.Summary, + }, true, nil +} + +func (wr *WorldRuntime) CreateQuest(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) { + _ = ctx + state, _, err := wr.ensureState() + if err != nil { + return nil, err + } + questID := normalizeWorldID(tools.MapStringArg(args, "id")) + if questID == "" { + questID = normalizeWorldID(tools.MapStringArg(args, "title")) + } + if questID == "" { + return nil, fmt.Errorf("id or title is required") + } + quest := state.ActiveQuests[questID] + quest.ID = questID + quest.Title = firstNonEmpty(strings.TrimSpace(tools.MapStringArg(args, "title")), quest.Title, questID) + quest.Status = firstNonEmpty(strings.TrimSpace(tools.MapStringArg(args, "status")), quest.Status, "open") + quest.OwnerNPCID = firstNonEmpty(strings.TrimSpace(tools.MapStringArg(args, "owner_npc_id")), quest.OwnerNPCID) + quest.Participants = append([]string(nil), tools.MapStringListArg(args, "participants")...) + quest.Summary = firstNonEmpty(strings.TrimSpace(tools.MapStringArg(args, "summary")), quest.Summary) + state.ActiveQuests[questID] = quest + if err := wr.store.SaveWorldState(state); err != nil { + return nil, err + } + evt := world.WorldEvent{ + ID: fmt.Sprintf("evt-quest-%d", time.Now().UnixNano()), + Type: "quest_updated", + Source: "world", + ActorID: quest.OwnerNPCID, + Content: questID, + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + _ = wr.store.AppendWorldEvent(evt) + return map[string]interface{}{"quest_id": questID}, nil +} + +func (wr *WorldRuntime) CreateNPC(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) { + _ = ctx + if wr == nil || wr.profiles == nil || wr.store == nil { + return nil, fmt.Errorf("world runtime not ready") + } + npcID := normalizeWorldID(tools.MapStringArg(args, "npc_id")) + if npcID == "" { + return nil, fmt.Errorf("npc_id is required") + } + persona := strings.TrimSpace(tools.MapStringArg(args, "persona")) + home := normalizeWorldID(tools.MapStringArg(args, "home_location")) + goals := tools.MapStringListArg(args, "default_goals") + if persona == "" || home == "" || len(goals) == 0 { + return nil, fmt.Errorf("persona, home_location, default_goals are required") + } + profile, err := wr.profiles.Upsert(tools.AgentProfile{ + AgentID: npcID, + Name: strings.TrimSpace(tools.MapStringArg(args, "name")), + Kind: "npc", + Persona: persona, + HomeLocation: home, + DefaultGoals: goals, + Role: strings.TrimSpace(tools.MapStringArg(args, "role")), + MemoryNamespace: npcID, + Status: "active", + }) + if err != nil { + return nil, err + } + state, npcStates, err := wr.ensureState() + if err != nil { + return nil, err + } + npcStates[profile.AgentID] = wr.engine.EnsureNPCState(wr.profileBlueprint(*profile), npcStates[profile.AgentID]) + if err := wr.store.SaveNPCStates(npcStates); err != nil { + return nil, err + } + evt := world.WorldEvent{ + ID: fmt.Sprintf("evt-create-npc-%d", time.Now().UnixNano()), + Type: "npc_created", + Source: "world", + ActorID: profile.AgentID, + LocationID: home, + Content: profile.Name, + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + wr.engine.AppendRecentEvent(&state, evt, 20) + if err := wr.store.SaveWorldState(state); err != nil { + return nil, err + } + if err := wr.store.AppendWorldEvent(evt); err != nil { + return nil, err + } + return map[string]interface{}{"npc_id": profile.AgentID}, nil +} + +func (wr *WorldRuntime) CreateEntity(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) { + _ = ctx + state, _, err := wr.ensureState() + if err != nil { + return nil, err + } + entityID := normalizeWorldID(firstNonEmpty(tools.MapStringArg(args, "entity_id"), tools.MapStringArg(args, "id"), tools.MapStringArg(args, "name"))) + locationID := normalizeWorldID(firstNonEmpty(tools.MapStringArg(args, "location_id"), tools.MapStringArg(args, "home_location"))) + if entityID == "" || locationID == "" { + return nil, fmt.Errorf("entity_id and location_id are required") + } + if _, ok := state.Locations[locationID]; !ok { + return nil, fmt.Errorf("unknown location_id: %s", locationID) + } + entity := state.Entities[entityID] + entity.ID = entityID + entity.Name = firstNonEmpty(tools.MapStringArg(args, "name"), entity.Name, entityID) + entity.Type = firstNonEmpty(tools.MapStringArg(args, "entity_type"), entity.Type, "landmark") + entity.LocationID = locationID + if entity.State == nil { + entity.State = map[string]interface{}{} + } + state.Entities[entityID] = entity + if err := wr.store.SaveWorldState(state); err != nil { + return nil, err + } + evt := world.WorldEvent{ + ID: fmt.Sprintf("evt-entity-%d", time.Now().UnixNano()), + Type: "entity_created", + Source: "world", + LocationID: locationID, + Content: entityID, + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + _ = wr.store.AppendWorldEvent(evt) + return map[string]interface{}{"entity_id": entityID}, nil +} + +func (wr *WorldRuntime) handleUserQuestInput(content string) (string, bool, error) { + content = strings.TrimSpace(content) + if content == "" { + return "", false, nil + } + state, _, err := wr.ensureState() + if err != nil { + return "", false, err + } + lower := strings.ToLower(content) + switch { + case containsAnyQuestPhrase(lower, "list quests", "show quests", "任务列表", "查看任务", "有哪些任务"): + if len(state.ActiveQuests) == 0 { + return "当剿²¡æœ‰æ´»è·ƒä»»åŠ¡ã€‚", true, nil + } + items := make([]string, 0, len(state.ActiveQuests)) + for _, quest := range state.ActiveQuests { + items = append(items, fmt.Sprintf("%s [%s]", firstNonEmpty(quest.Title, quest.ID), firstNonEmpty(quest.Status, "open"))) + } + sort.Strings(items) + return "当å‰ä»»åŠ¡ï¼š\n" + strings.Join(items, "\n"), true, nil + case containsAnyQuestPhrase(lower, "accept quest", "take quest", "接å—任务", "接å–任务"): + id := resolveQuestReference(content, state) + if id == "" { + return "æ²¡æœ‰æ‰¾åˆ°è¦æŽ¥å—的任务。", true, nil + } + quest := state.ActiveQuests[id] + quest.Status = "accepted" + state.ActiveQuests[id] = quest + if err := wr.saveQuestMutation(state, quest, "quest_accepted"); err != nil { + return "", true, err + } + return fmt.Sprintf("已接å—任务:%s", firstNonEmpty(quest.Title, quest.ID)), true, nil + case containsAnyQuestPhrase(lower, "complete quest", "finish quest", "完æˆä»»åŠ¡"): + id := resolveQuestReference(content, state) + if id == "" { + return "没有找到è¦å®Œæˆçš„任务。", true, nil + } + quest := state.ActiveQuests[id] + quest.Status = "completed" + state.ActiveQuests[id] = quest + if err := wr.saveQuestMutation(state, quest, "quest_completed"); err != nil { + return "", true, err + } + return fmt.Sprintf("已完æˆä»»åŠ¡ï¼š%s", firstNonEmpty(quest.Title, quest.ID)), true, nil + case containsAnyQuestPhrase(lower, "progress quest", "advance quest", "推进任务", "更新任务进度"): + id := resolveQuestReference(content, state) + if id == "" { + return "æ²¡æœ‰æ‰¾åˆ°è¦æŽ¨è¿›çš„ä»»åŠ¡ã€‚", true, nil + } + quest := state.ActiveQuests[id] + quest.Status = "in_progress" + if summary := extractTailAfterQuestVerb(content); summary != "" { + quest.Summary = summary + } + state.ActiveQuests[id] = quest + if err := wr.saveQuestMutation(state, quest, "quest_progressed"); err != nil { + return "", true, err + } + return fmt.Sprintf("已推进任务:%s", firstNonEmpty(quest.Title, quest.ID)), true, nil + default: + return "", false, nil + } +} + +func (wr *WorldRuntime) saveQuestMutation(state world.WorldState, quest world.QuestState, eventType string) error { + if err := wr.store.SaveWorldState(state); err != nil { + return err + } + evt := world.WorldEvent{ + ID: fmt.Sprintf("evt-%s-%d", eventType, time.Now().UnixNano()), + Type: eventType, + Source: "user", + Content: quest.ID, + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + return wr.store.AppendWorldEvent(evt) +} + +func (wr *WorldRuntime) HandleUserInput(ctx context.Context, content, channel, chatID string) (string, error) { + _ = channel + _ = chatID + if out, handled, err := wr.handleUserQuestInput(content); handled || err != nil { + return out, err + } + res, err := wr.advance(ctx, world.WorldTickRequest{ + Source: "user", + UserInput: content, + LocationID: "commons", + }) + if err != nil { + return "", err + } + return res.Text, nil +} + +func (wr *WorldRuntime) advance(ctx context.Context, req world.WorldTickRequest) (world.RenderedResult, error) { + state, npcStates, err := wr.ensureState() + if err != nil { + return world.RenderedResult{}, err + } + catchUp := wr.computeCatchUp(state) + if req.CatchUpTicks > 0 { + catchUp = req.CatchUpTicks + } + if catchUp > wr.maxCatchUp { + catchUp = wr.maxCatchUp + } + var recentEvents []world.WorldEvent + for i := 0; i < catchUp; i++ { + wr.engine.NextTick(&state) + res, err := wr.runTick(ctx, &state, npcStates, nil, "background") + if err != nil { + return world.RenderedResult{}, err + } + recentEvents = append(recentEvents, res.RecentEvents...) + } + var userEvent *world.WorldEvent + if strings.TrimSpace(req.UserInput) != "" { + wr.engine.NextTick(&state) + evt := wr.engine.BuildUserEvent(&state, req.UserInput, req.LocationID) + userEvent = &evt + wr.engine.AppendRecentEvent(&state, evt, 20) + if err := wr.store.AppendWorldEvent(evt); err != nil { + return world.RenderedResult{}, err + } + } + var visible []world.WorldEvent + if userEvent != nil { + visible = append(visible, *userEvent) + } + res, err := wr.runTick(ctx, &state, npcStates, visible, firstNonEmpty(req.Source, "user")) + if err != nil { + return world.RenderedResult{}, err + } + recentEvents = append(recentEvents, res.RecentEvents...) + if err := wr.store.SaveNPCStates(npcStates); err != nil { + return world.RenderedResult{}, err + } + if err := wr.store.SaveWorldState(state); err != nil { + return world.RenderedResult{}, err + } + res.RecentEvents = recentEvents + if strings.TrimSpace(res.Text) == "" { + res.Text = wr.renderEvents(userEvent, recentEvents) + } + return res, nil +} + +func (wr *WorldRuntime) runTick(ctx context.Context, state *world.WorldState, npcStates map[string]world.NPCState, seedEvents []world.WorldEvent, source string) (world.RenderedResult, error) { + profiles, err := wr.worldProfiles() + if err != nil { + return world.RenderedResult{}, err + } + maxNPC := wr.maxNPCPerTick + if maxNPC <= 0 { + maxNPC = len(profiles) + } + intents := make([]world.ActionIntent, 0, maxNPC) + appliedEvents := make([]world.WorldEvent, 0, maxNPC) + count := 0 + for _, profile := range profiles { + if count >= maxNPC { + break + } + npcState := npcStates[profile.AgentID] + visible := wr.engine.VisibleEventsForNPC(*state, npcState, seedEvents, profile.PerceptionScope) + if len(visible) == 0 && !wr.shouldWakeNPC(profile, npcState, state.Clock.Tick) { + continue + } + intent, err := wr.decideNPCIntent(ctx, *state, profile, npcState, visible) + if err != nil { + return world.RenderedResult{}, err + } + intents = append(intents, intent) + delta := wr.engine.ApplyIntent(state, &npcState, intent) + if delta.Applied && strings.EqualFold(strings.TrimSpace(intent.Action), "delegate") && wr.manager != nil && strings.TrimSpace(intent.TargetAgent) != "" { + _, _ = wr.manager.SendAgentMessage( + profile.AgentID, + strings.TrimSpace(intent.TargetAgent), + "delegate", + firstNonEmpty(intent.Speech, intent.TargetEntity, "delegated task"), + "", + ) + } + if !delta.Applied { + rejected := world.WorldEvent{ + ID: fmt.Sprintf("evt-rejected-%d", time.Now().UnixNano()), + Type: "rejected_intent", + Source: source, + ActorID: intent.ActorID, + LocationID: npcState.CurrentLocation, + Content: delta.Reason, + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + wr.engine.AppendRecentEvent(state, rejected, 20) + if err := wr.store.AppendWorldEvent(rejected); err != nil { + return world.RenderedResult{}, err + } + appliedEvents = append(appliedEvents, rejected) + } else if delta.Event != nil { + npcStates[profile.AgentID] = npcState + wr.engine.AppendRecentEvent(state, *delta.Event, 20) + if err := wr.store.AppendWorldEvent(*delta.Event); err != nil { + return world.RenderedResult{}, err + } + appliedEvents = append(appliedEvents, *delta.Event) + } + count++ + } + return world.RenderedResult{ + Text: wr.renderEvents(nil, appliedEvents), + Tick: state.Clock.Tick, + Intents: intents, + RecentEvents: appliedEvents, + }, nil +} + +func (wr *WorldRuntime) decideNPCIntent(ctx context.Context, state world.WorldState, profile tools.AgentProfile, npcState world.NPCState, visible []world.WorldEvent) (world.ActionIntent, error) { + if wr.dispatcher == nil { + return wr.fallbackIntent(profile, npcState, visible, state), nil + } + worldSnapshot := map[string]interface{}{ + "tick": state.Clock.Tick, + "locations": state.Locations, + "global_facts": state.GlobalFacts, + } + npcSnapshot := map[string]interface{}{ + "npc_id": npcState.NPCID, + "display_name": profile.Name, + "persona": profile.Persona, + "traits": append([]string(nil), profile.Traits...), + "current_location": npcState.CurrentLocation, + "goals_long_term": append([]string(nil), npcState.Goals.LongTerm...), + "goals_short_term": append([]string(nil), npcState.Goals.ShortTerm...), + } + visibleMaps := make([]map[string]interface{}, 0, len(visible)) + for _, evt := range visible { + visibleMaps = append(visibleMaps, map[string]interface{}{ + "id": evt.ID, + "type": evt.Type, + "actor_id": evt.ActorID, + "location_id": evt.LocationID, + "content": evt.Content, + "tick": evt.Tick, + }) + } + taskText := wr.buildDecisionTask(profile, npcState, visible) + task, err := wr.dispatcher.DispatchTask(ctx, tools.AgentDispatchRequest{ + Task: taskText, + RunKind: "world_npc", + AgentID: profile.AgentID, + Origin: &tools.OriginRef{Channel: "world", ChatID: "world"}, + WorldDecision: &tools.WorldDecisionContext{ + WorldTick: state.Clock.Tick, + WorldSnapshot: worldSnapshot, + NPCSnapshot: npcSnapshot, + VisibleEvents: visibleMaps, + IntentSchemaVersion: "v1", + }, + }) + if err != nil { + return wr.fallbackIntent(profile, npcState, visible, state), nil + } + reply, err := wr.dispatcher.WaitReply(ctx, task.ID, 100*time.Millisecond) + if err != nil { + return wr.fallbackIntent(profile, npcState, visible, state), nil + } + intent, err := parseWorldIntent(reply.Result) + if err != nil || strings.TrimSpace(intent.Action) == "" { + return wr.fallbackIntent(profile, npcState, visible, state), nil + } + if strings.TrimSpace(intent.ActorID) == "" { + intent.ActorID = profile.AgentID + } + return intent, nil +} + +func (wr *WorldRuntime) fallbackIntent(profile tools.AgentProfile, npcState world.NPCState, visible []world.WorldEvent, state world.WorldState) world.ActionIntent { + intent := world.ActionIntent{ + ActorID: profile.AgentID, + Action: "wait", + } + for _, evt := range visible { + if evt.Type == "user_input" && evt.LocationID == npcState.CurrentLocation { + speech := fmt.Sprintf("%s notices the user: %s", firstNonEmpty(profile.Name, profile.AgentID), strings.TrimSpace(evt.Content)) + return world.ActionIntent{ + ActorID: profile.AgentID, + Action: "speak", + Speech: speech, + InternalReasoningSummary: "responded to nearby user activity", + } + } + } + for _, goal := range npcState.Goals.LongTerm { + g := strings.ToLower(strings.TrimSpace(goal)) + if strings.Contains(g, "patrol") { + loc := state.Locations[npcState.CurrentLocation] + if len(loc.Neighbors) > 0 { + return world.ActionIntent{ + ActorID: profile.AgentID, + Action: "move", + TargetLocation: loc.Neighbors[0], + InternalReasoningSummary: "patrolling according to long-term goal", + } + } + } + if strings.Contains(g, "watch") || strings.Contains(g, "guard") || strings.Contains(g, "observe") { + return world.ActionIntent{ + ActorID: profile.AgentID, + Action: "observe", + InternalReasoningSummary: "maintains awareness of the surroundings", + } + } + } + return intent +} + +func (wr *WorldRuntime) buildDecisionTask(profile tools.AgentProfile, npcState world.NPCState, visible []world.WorldEvent) string { + payload := map[string]interface{}{ + "npc_id": profile.AgentID, + "display_name": profile.Name, + "persona": profile.Persona, + "traits": profile.Traits, + "current_location": npcState.CurrentLocation, + "long_term_goals": npcState.Goals.LongTerm, + "short_term_goals": npcState.Goals.ShortTerm, + "visible_events": visible, + "allowed_actions": []string{"move", "speak", "observe", "interact", "delegate", "wait"}, + "response_contract": "return JSON object with actor_id, action, target_location, target_entity, target_agent, speech, internal_reasoning_summary, proposed_effects", + } + data, _ := json.Marshal(payload) + return string(data) +} + +func (wr *WorldRuntime) ensureState() (world.WorldState, map[string]world.NPCState, error) { + state, err := wr.store.LoadWorldState() + if err != nil { + return world.WorldState{}, nil, err + } + wr.engine.EnsureWorld(&state) + npcStates, err := wr.store.LoadNPCStates() + if err != nil { + return world.WorldState{}, nil, err + } + profiles, err := wr.worldProfiles() + if err != nil { + return world.WorldState{}, nil, err + } + changed := false + for _, profile := range profiles { + current, exists := npcStates[profile.AgentID] + next := wr.engine.EnsureNPCState(wr.profileBlueprint(profile), current) + if !exists || strings.TrimSpace(current.NPCID) == "" || strings.TrimSpace(current.CurrentLocation) == "" { + changed = true + } + npcStates[profile.AgentID] = next + } + if changed { + if err := wr.store.SaveNPCStates(npcStates); err != nil { + return world.WorldState{}, nil, err + } + } + if err := wr.store.SaveWorldState(state); err != nil { + return world.WorldState{}, nil, err + } + return state, npcStates, nil +} + +func (wr *WorldRuntime) worldProfiles() ([]tools.AgentProfile, error) { + if wr == nil || wr.profiles == nil { + return nil, nil + } + items, err := wr.profiles.List() + if err != nil { + return nil, err + } + out := make([]tools.AgentProfile, 0, len(items)) + for _, item := range items { + if !strings.EqualFold(strings.TrimSpace(item.Status), "active") { + continue + } + if !isWorldNPCProfile(item) { + continue + } + out = append(out, item) + } + sort.Slice(out, func(i, j int) bool { return out[i].AgentID < out[j].AgentID }) + return out, nil +} + +func isWorldNPCProfile(profile tools.AgentProfile) bool { + if !strings.EqualFold(strings.TrimSpace(profile.Kind), "npc") { + return false + } + return strings.TrimSpace(profile.HomeLocation) != "" || + strings.TrimSpace(profile.Persona) != "" || + len(profile.DefaultGoals) > 0 || + len(profile.WorldTags) > 0 +} + +func (wr *WorldRuntime) profileBlueprint(profile tools.AgentProfile) world.NPCBlueprint { + return world.NPCBlueprint{ + NPCID: profile.AgentID, + DisplayName: profile.Name, + Kind: profile.Kind, + Role: profile.Role, + Persona: profile.Persona, + Traits: append([]string(nil), profile.Traits...), + Faction: profile.Faction, + HomeLocation: firstNonEmpty(profile.HomeLocation, "commons"), + DefaultGoals: append([]string(nil), profile.DefaultGoals...), + PerceptionScope: profile.PerceptionScope, + ScheduleHint: profile.ScheduleHint, + WorldTags: append([]string(nil), profile.WorldTags...), + MemoryNamespace: profile.MemoryNamespace, + PromptFile: profile.PromptFile, + } +} + +func (wr *WorldRuntime) shouldWakeNPC(profile tools.AgentProfile, state world.NPCState, tick int64) bool { + if tick == 0 { + return true + } + if len(state.Goals.LongTerm) == 0 { + return false + } + if state.LastActiveTick == 0 { + return true + } + return tick-state.LastActiveTick >= 2 +} + +func (wr *WorldRuntime) computeCatchUp(state world.WorldState) int { + if state.Clock.LastAdvance <= 0 || state.Clock.TickDuration <= 0 { + return 0 + } + delta := time.Now().Unix() - state.Clock.LastAdvance + if delta <= state.Clock.TickDuration { + return 0 + } + return int(delta / state.Clock.TickDuration) +} + +func (wr *WorldRuntime) renderEvents(userEvent *world.WorldEvent, events []world.WorldEvent) string { + lines := make([]string, 0, len(events)+1) + if userEvent != nil && strings.TrimSpace(userEvent.Content) != "" { + lines = append(lines, "世界感知到你的行动:"+strings.TrimSpace(userEvent.Content)) + } + for _, evt := range events { + switch evt.Type { + case "npc_speak": + lines = append(lines, fmt.Sprintf("%s 说:%s", evt.ActorID, strings.TrimSpace(evt.Content))) + case "npc_move": + lines = append(lines, fmt.Sprintf("%s 移动到了 %s", evt.ActorID, strings.TrimSpace(evt.LocationID))) + case "rejected_intent": + lines = append(lines, fmt.Sprintf("%s 的行动被世界拒ç»ï¼š%s", evt.ActorID, strings.TrimSpace(evt.Content))) + case "npc_observe": + lines = append(lines, fmt.Sprintf("%s 正在观察局势", evt.ActorID)) + case "npc_interact": + lines = append(lines, fmt.Sprintf("%s å‘起了交互:%s", evt.ActorID, strings.TrimSpace(evt.Content))) + case "npc_delegate": + lines = append(lines, fmt.Sprintf("%s å‘出了委托:%s", evt.ActorID, strings.TrimSpace(evt.Content))) + } + } + if len(lines) == 0 { + return "世界安é™åœ°æŽ¨è¿›äº†ä¸€æ‹ã€‚" + } + return strings.Join(lines, "\n") +} + +func parseWorldIntent(raw string) (world.ActionIntent, error) { + var intent world.ActionIntent + if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &intent); err != nil { + return world.ActionIntent{}, err + } + return intent, nil +} + +func normalizeWorldID(in string) string { + in = strings.TrimSpace(strings.ToLower(in)) + if in == "" { + return "" + } + var sb strings.Builder + for _, r := range in { + switch { + case r >= 'a' && r <= 'z': + sb.WriteRune(r) + case r >= '0' && r <= '9': + sb.WriteRune(r) + case r == '-' || r == '_' || r == '.': + sb.WriteRune(r) + case r == ' ': + sb.WriteRune('-') + } + } + return strings.Trim(sb.String(), "-_.") +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func containsAnyQuestPhrase(text string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(text, strings.ToLower(strings.TrimSpace(needle))) { + return true + } + } + return false +} + +func resolveQuestReference(content string, state world.WorldState) string { + content = strings.ToLower(strings.TrimSpace(content)) + for id, quest := range state.ActiveQuests { + if strings.Contains(content, strings.ToLower(id)) { + return id + } + if strings.TrimSpace(quest.Title) != "" && strings.Contains(content, strings.ToLower(quest.Title)) { + return id + } + } + return "" +} + +func extractTailAfterQuestVerb(content string) string { + raw := strings.TrimSpace(content) + lower := strings.ToLower(raw) + for _, marker := range []string{"推进任务", "更新任务进度", "progress quest", "advance quest"} { + idx := strings.Index(lower, strings.ToLower(marker)) + if idx >= 0 { + out := strings.TrimSpace(raw[idx+len(marker):]) + out = strings.Trim(out, " :,-") + return out + } + } + return "" +} diff --git a/pkg/agent/world_runtime_test.go b/pkg/agent/world_runtime_test.go new file mode 100644 index 0000000..25ddc0a --- /dev/null +++ b/pkg/agent/world_runtime_test.go @@ -0,0 +1,527 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/YspCoder/clawgo/pkg/providers" + "github.com/YspCoder/clawgo/pkg/tools" + "github.com/YspCoder/clawgo/pkg/world" +) + +func TestWorldRuntimeHandleUserInputInitializesState(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewAgentManager(nil, workspace, nil) + store := manager.ProfileStore() + if store == nil { + t.Fatalf("expected profile store") + } + if _, err := store.Upsert(tools.AgentProfile{ + AgentID: "keeper", + Name: "Keeper", + Kind: "npc", + Persona: "A calm keeper of the commons.", + HomeLocation: "commons", + DefaultGoals: []string{"watch the square"}, + Status: "active", + }); err != nil { + t.Fatalf("profile upsert failed: %v", err) + } + manager.SetRunFunc(func(ctx context.Context, task *tools.AgentTask) (string, error) { + out, _ := json.Marshal(map[string]interface{}{ + "actor_id": task.AgentID, + "action": "speak", + "speech": "I saw the user arrive.", + }) + return string(out), nil + }) + runtime := NewWorldRuntime(workspace, store, tools.NewAgentDispatcher(manager), manager) + + out, err := runtime.HandleUserInput(context.Background(), "I enter the commons.", "cli", "direct") + if err != nil { + t.Fatalf("handle user input failed: %v", err) + } + if !strings.Contains(out, "keeper") && !strings.Contains(out, "I saw the user arrive") { + t.Fatalf("unexpected world response: %q", out) + } + + for _, name := range []string{"world_state.json", "npc_state.json", "world_events.jsonl"} { + if _, err := os.Stat(filepath.Join(workspace, "agents", "runtime", name)); err != nil { + t.Fatalf("expected world artifact %s: %v", name, err) + } + } + + snapshot, err := runtime.Snapshot(10) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + data, _ := json.Marshal(snapshot) + if !strings.Contains(string(data), "\"npc_count\":1") { + t.Fatalf("expected snapshot npc_count=1, got %s", string(data)) + } +} + +func TestWorldRuntimeTickSupportsAutonomousNPCAction(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewAgentManager(nil, workspace, nil) + store := manager.ProfileStore() + if store == nil { + t.Fatalf("expected profile store") + } + if _, err := store.Upsert(tools.AgentProfile{ + AgentID: "patroller", + Name: "Patroller", + Kind: "npc", + Persona: "Walks the route.", + HomeLocation: "commons", + DefaultGoals: []string{"patrol the area"}, + Status: "active", + }); err != nil { + t.Fatalf("profile upsert failed: %v", err) + } + manager.SetRunFunc(func(ctx context.Context, task *tools.AgentTask) (string, error) { + out, _ := json.Marshal(map[string]interface{}{ + "actor_id": task.AgentID, + "action": "move", + "target_location": "square", + }) + return string(out), nil + }) + runtime := NewWorldRuntime(workspace, store, tools.NewAgentDispatcher(manager), manager) + + out, err := runtime.Tick(context.Background(), "test") + if err != nil { + t.Fatalf("tick failed: %v", err) + } + if !strings.Contains(out, "square") { + t.Fatalf("expected move narration, got %q", out) + } + npc, found, err := runtime.NPCGet("patroller") + if err != nil || !found { + t.Fatalf("expected npc state after tick, found=%v err=%v", found, err) + } + data, _ := json.Marshal(npc) + if !strings.Contains(string(data), "\"current_location\":\"square\"") { + t.Fatalf("expected current_location square, got %s", string(data)) + } +} + +func TestWorldRuntimeCreateNPCAndSnapshot(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewAgentManager(nil, workspace, nil) + runtime := NewWorldRuntime(workspace, manager.ProfileStore(), tools.NewAgentDispatcher(manager), manager) + + created, err := runtime.CreateNPC(context.Background(), map[string]interface{}{ + "npc_id": "merchant", + "name": "Merchant", + "persona": "Talkative trader", + "home_location": "square", + "default_goals": []string{"watch trade"}, + }) + if err != nil { + t.Fatalf("create npc failed: %v", err) + } + if got := strings.TrimSpace(tools.MapStringArg(created, "npc_id")); got != "merchant" { + t.Fatalf("unexpected created npc id: %q", got) + } + + snapshotOut, err := runtime.Snapshot(10) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + data, _ := json.Marshal(snapshotOut) + if !strings.Contains(string(data), "\"merchant\"") { + t.Fatalf("expected snapshot to include merchant: %s", string(data)) + } + events, err := runtime.EventLog(10) + if err != nil { + t.Fatalf("event log failed: %v", err) + } + if len(events) == 0 { + t.Fatalf("expected npc_created event") + } +} + +func TestWorldRuntimeCreateEntityAndGet(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewAgentManager(nil, workspace, nil) + runtime := NewWorldRuntime(workspace, manager.ProfileStore(), tools.NewAgentDispatcher(manager), manager) + created, err := runtime.CreateEntity(context.Background(), map[string]interface{}{ + "entity_id": "statue", + "name": "Old Statue", + "entity_type": "landmark", + "location_id": "square", + }) + if err != nil { + t.Fatalf("create entity failed: %v", err) + } + if got := strings.TrimSpace(tools.MapStringArg(created, "entity_id")); got != "statue" { + t.Fatalf("unexpected entity id: %q", got) + } + entity, found, err := runtime.EntityGet("statue") + if err != nil || !found { + t.Fatalf("expected entity, found=%v err=%v", found, err) + } + if got := strings.TrimSpace(fmt.Sprint(entity["location_id"])); got != "square" { + t.Fatalf("expected entity in square, got %q", got) + } +} + +func TestWorldRuntimeSnapshotIncludesEntityOccupancyAfterInteract(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewAgentManager(nil, workspace, nil) + store := manager.ProfileStore() + if store == nil { + t.Fatalf("expected profile store") + } + if _, err := store.Upsert(tools.AgentProfile{ + AgentID: "caretaker", + Name: "Caretaker", + Kind: "npc", + Persona: "Maintains landmarks.", + HomeLocation: "square", + DefaultGoals: []string{"maintain landmarks"}, + Status: "active", + }); err != nil { + t.Fatalf("profile upsert failed: %v", err) + } + runtime := NewWorldRuntime(workspace, store, tools.NewAgentDispatcher(manager), manager) + worldOut, err := runtime.WorldGet() + if err != nil { + t.Fatalf("world get failed: %v", err) + } + worldState, ok := worldOut["world_state"].(world.WorldState) + if !ok { + t.Fatalf("unexpected world_state payload: %T", worldOut["world_state"]) + } + worldState.Entities["statue"] = world.Entity{ID: "statue", LocationID: "square", State: map[string]interface{}{}} + if err := runtime.store.SaveWorldState(worldState); err != nil { + t.Fatalf("save world state failed: %v", err) + } + manager.SetRunFunc(func(ctx context.Context, task *tools.AgentTask) (string, error) { + return `{"actor_id":"caretaker","action":"interact","target_entity":"statue","speech":"polishes the statue"}`, nil + }) + if _, err := runtime.Tick(context.Background(), "interact"); err != nil { + t.Fatalf("tick failed: %v", err) + } + snap, err := runtime.Snapshot(10) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + data, _ := json.Marshal(snap) + if !strings.Contains(string(data), `"entity_occupancy":{"square":["statue"]}`) { + t.Fatalf("expected entity occupancy for statue, got %s", string(data)) + } +} + +func TestHandleRuntimeAdminSnapshotIncludesWorld(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewAgentManager(nil, workspace, nil) + store := manager.ProfileStore() + if store == nil { + t.Fatalf("expected profile store") + } + if _, err := store.Upsert(tools.AgentProfile{ + AgentID: "watcher", + Name: "Watcher", + Kind: "npc", + Persona: "Keeps watch.", + HomeLocation: "commons", + DefaultGoals: []string{"watch"}, + Status: "active", + }); err != nil { + t.Fatalf("profile upsert failed: %v", err) + } + manager.SetRunFunc(func(ctx context.Context, task *tools.AgentTask) (string, error) { + return `{"actor_id":"watcher","action":"observe","internal_reasoning_summary":"on watch"}`, nil + }) + worldRuntime := NewWorldRuntime(workspace, store, tools.NewAgentDispatcher(manager), manager) + loop := &AgentLoop{ + agentManager: manager, + agentDispatcher: tools.NewAgentDispatcher(manager), + worldRuntime: worldRuntime, + } + if _, err := worldRuntime.Tick(context.Background(), "seed"); err != nil { + t.Fatalf("world tick failed: %v", err) + } + + out, err := loop.HandleRuntimeAdmin(context.Background(), "snapshot", map[string]interface{}{"limit": 10}) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + payload, ok := out.(map[string]interface{}) + if !ok { + t.Fatalf("unexpected payload type: %T", out) + } + snapshot, ok := payload["snapshot"].(tools.RuntimeSnapshot) + if !ok { + t.Fatalf("unexpected snapshot type: %T", payload["snapshot"]) + } + if snapshot.World == nil { + t.Fatalf("expected world snapshot in runtime snapshot") + } +} + +func TestWorldRuntimeDelegateSendsMailboxMessage(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewAgentManager(nil, workspace, nil) + store := manager.ProfileStore() + if store == nil { + t.Fatalf("expected profile store") + } + for _, profile := range []tools.AgentProfile{ + { + AgentID: "chief", + Name: "Chief", + Kind: "npc", + Persona: "Delegates work.", + HomeLocation: "commons", + DefaultGoals: []string{"coordinate"}, + Status: "active", + }, + { + AgentID: "scout", + Name: "Scout", + Kind: "npc", + Persona: "Explores.", + HomeLocation: "commons", + DefaultGoals: []string{"patrol"}, + Status: "active", + }, + } { + if _, err := store.Upsert(profile); err != nil { + t.Fatalf("profile upsert failed: %v", err) + } + } + manager.SetRunFunc(func(ctx context.Context, task *tools.AgentTask) (string, error) { + if task.AgentID == "chief" { + return `{"actor_id":"chief","action":"delegate","target_agent":"scout","speech":"Check the square."}`, nil + } + return `{"actor_id":"scout","action":"wait"}`, nil + }) + runtime := NewWorldRuntime(workspace, store, tools.NewAgentDispatcher(manager), manager) + + if _, err := runtime.Tick(context.Background(), "delegate"); err != nil { + t.Fatalf("tick failed: %v", err) + } + msgs, err := manager.Inbox("scout", 10) + if err != nil { + t.Fatalf("inbox failed: %v", err) + } + if len(msgs) == 0 { + t.Fatalf("expected delegate message in scout inbox") + } + found := false + for _, msg := range msgs { + if msg.Type != "delegate" { + continue + } + if !strings.Contains(msg.Content, "Check the square") { + t.Fatalf("unexpected delegate content: %+v", msg) + } + found = true + break + } + if !found { + t.Fatalf("expected delegate message in inbox, got %+v", msgs) + } +} + +func TestHandleRuntimeAdminWorldActions(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewAgentManager(nil, workspace, nil) + worldRuntime := NewWorldRuntime(workspace, manager.ProfileStore(), tools.NewAgentDispatcher(manager), manager) + loop := &AgentLoop{ + agentManager: manager, + agentDispatcher: tools.NewAgentDispatcher(manager), + worldRuntime: worldRuntime, + } + + out, err := loop.HandleRuntimeAdmin(context.Background(), "world_npc_create", map[string]interface{}{ + "npc_id": "merchant", + "name": "Merchant", + "persona": "Talkative trader", + "home_location": "square", + "default_goals": []string{"watch trade"}, + }) + if err != nil { + t.Fatalf("world_npc_create failed: %v", err) + } + payload, ok := out.(map[string]interface{}) + if !ok || strings.TrimSpace(tools.MapStringArg(payload, "npc_id")) != "merchant" { + t.Fatalf("unexpected create payload: %#v", out) + } + out, err = loop.HandleRuntimeAdmin(context.Background(), "world_npc_list", nil) + if err != nil { + t.Fatalf("world_npc_list failed: %v", err) + } + listPayload, ok := out.(map[string]interface{}) + if !ok { + t.Fatalf("unexpected list payload: %T", out) + } + items, ok := listPayload["items"].([]map[string]interface{}) + if !ok || len(items) == 0 { + t.Fatalf("expected world npc list items, got %#v", listPayload["items"]) + } + out, err = loop.HandleRuntimeAdmin(context.Background(), "world_quest_create", map[string]interface{}{ + "id": "meet-merchant", + "title": "Meet Merchant", + "owner_npc_id": "merchant", + "summary": "Find the merchant in the square.", + }) + if err != nil { + t.Fatalf("world_quest_create failed: %v", err) + } + questPayload, ok := out.(map[string]interface{}) + if !ok || strings.TrimSpace(tools.MapStringArg(questPayload, "quest_id")) != "meet-merchant" { + t.Fatalf("unexpected quest create payload: %#v", out) + } + out, err = loop.HandleRuntimeAdmin(context.Background(), "world_quest_list", nil) + if err != nil { + t.Fatalf("world_quest_list failed: %v", err) + } + listPayload, ok = out.(map[string]interface{}) + if !ok { + t.Fatalf("unexpected quest list payload: %T", out) + } + if _, ok := listPayload["items"].([]map[string]interface{}); !ok { + t.Fatalf("expected quest list items, got %#v", listPayload["items"]) + } +} + +type worldDecisionStubProvider struct { + content string +} + +func (p worldDecisionStubProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) { + return &providers.LLMResponse{Content: p.content, FinishReason: "stop"}, nil +} + +func (p worldDecisionStubProvider) GetDefaultModel() string { return "stub-world-model" } + +func TestRunWorldDecisionTaskUsesLLMJSONWhenAvailable(t *testing.T) { + loop := &AgentLoop{ + provider: worldDecisionStubProvider{ + content: `{"actor_id":"keeper","action":"speak","speech":"Welcome to the square.","internal_reasoning_summary":"greets newcomers"}`, + }, + } + out, err := loop.runWorldDecisionTask(context.Background(), &tools.AgentTask{ + AgentID: "keeper", + RunKind: "world_npc", + Task: `{"npc_id":"keeper"}`, + WorldDecision: &tools.WorldDecisionContext{ + NPCSnapshot: map[string]interface{}{ + "display_name": "Keeper", + }, + }, + }) + if err != nil { + t.Fatalf("runWorldDecisionTask failed: %v", err) + } + if !strings.Contains(out, `"Welcome to the square."`) { + t.Fatalf("expected llm JSON to be used, got %s", out) + } +} + +func TestRunWorldDecisionTaskFallsBackWhenLLMOutputInvalid(t *testing.T) { + loop := &AgentLoop{ + provider: worldDecisionStubProvider{ + content: `not-json at all`, + }, + } + out, err := loop.runWorldDecisionTask(context.Background(), &tools.AgentTask{ + AgentID: "keeper", + RunKind: "world_npc", + Task: `{"npc_id":"keeper"}`, + WorldDecision: &tools.WorldDecisionContext{ + NPCSnapshot: map[string]interface{}{ + "display_name": "Keeper", + "current_location": "commons", + }, + VisibleEvents: []map[string]interface{}{ + { + "type": "user_input", + "location_id": "commons", + "content": "I walk into the commons.", + }, + }, + }, + }) + if err != nil { + t.Fatalf("runWorldDecisionTask failed: %v", err) + } + if !strings.Contains(out, `"action":"speak"`) { + t.Fatalf("expected fallback speak intent, got %s", out) + } +} + +func TestWorldRuntimeHandleUserInputQuestCommands(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewAgentManager(nil, workspace, nil) + runtime := NewWorldRuntime(workspace, manager.ProfileStore(), tools.NewAgentDispatcher(manager), manager) + if _, err := runtime.CreateQuest(context.Background(), map[string]interface{}{ + "id": "meet-merchant", + "title": "Meet Merchant", + "summary": "Find the merchant in the square.", + }); err != nil { + t.Fatalf("create quest failed: %v", err) + } + + out, err := runtime.HandleUserInput(context.Background(), "查看任务", "cli", "direct") + if err != nil { + t.Fatalf("list quest input failed: %v", err) + } + if !strings.Contains(out, "Meet Merchant") { + t.Fatalf("expected quest list output, got %q", out) + } + + out, err = runtime.HandleUserInput(context.Background(), "接å—任务 Meet Merchant", "cli", "direct") + if err != nil { + t.Fatalf("accept quest input failed: %v", err) + } + if !strings.Contains(out, "已接å—任务") { + t.Fatalf("expected accept output, got %q", out) + } + quest, found, err := runtime.QuestGet("meet-merchant") + if err != nil || !found { + t.Fatalf("expected quest after accept, found=%v err=%v", found, err) + } + if got := strings.TrimSpace(fmt.Sprint(quest["status"])); got != "accepted" { + t.Fatalf("expected accepted status, got %q", got) + } + + out, err = runtime.HandleUserInput(context.Background(), "推进任务 Meet Merchant 已抵达广场", "cli", "direct") + if err != nil { + t.Fatalf("progress quest input failed: %v", err) + } + if !strings.Contains(out, "已推进任务") { + t.Fatalf("expected progress output, got %q", out) + } + quest, found, err = runtime.QuestGet("meet-merchant") + if err != nil || !found { + t.Fatalf("expected quest after progress, found=%v err=%v", found, err) + } + if got := strings.TrimSpace(fmt.Sprint(quest["status"])); got != "in_progress" { + t.Fatalf("expected in_progress status, got %q", got) + } + + out, err = runtime.HandleUserInput(context.Background(), "完æˆä»»åŠ¡ Meet Merchant", "cli", "direct") + if err != nil { + t.Fatalf("complete quest input failed: %v", err) + } + if !strings.Contains(out, "已完æˆä»»åŠ¡") { + t.Fatalf("expected complete output, got %q", out) + } + quest, found, err = runtime.QuestGet("meet-merchant") + if err != nil || !found { + t.Fatalf("expected quest after complete, found=%v err=%v", found, err) + } + if got := strings.TrimSpace(fmt.Sprint(quest["status"])); got != "completed" { + t.Fatalf("expected completed status, got %q", got) + } +} diff --git a/pkg/api/rpc_http.go b/pkg/api/rpc_http.go index e796ee5..8a7e452 100644 --- a/pkg/api/rpc_http.go +++ b/pkg/api/rpc_http.go @@ -7,13 +7,8 @@ import ( "strings" rpcpkg "github.com/YspCoder/clawgo/pkg/rpc" - "github.com/YspCoder/clawgo/pkg/tools" ) -func (s *Server) handleSubagentRPC(w http.ResponseWriter, r *http.Request) { - s.handleRPC(w, r, s.subagentRPCRegistry()) -} - func (s *Server) handleNodeRPC(w http.ResponseWriter, r *http.Request) { s.handleRPC(w, r, s.nodeRPCRegistry()) } @@ -64,43 +59,6 @@ func (s *Server) handleRPC(w http.ResponseWriter, r *http.Request, registry *rpc }) } -func (s *Server) buildSubagentRegistry() *rpcpkg.Registry { - svc := s.subagentRPCService() - reg := rpcpkg.NewRegistry() - rpcpkg.RegisterJSON(reg, "subagent.list", func(ctx context.Context, req rpcpkg.ListSubagentsRequest) (interface{}, *rpcpkg.Error) { - return svc.List(ctx, req) - }) - rpcpkg.RegisterJSON(reg, "subagent.snapshot", func(ctx context.Context, req rpcpkg.SnapshotRequest) (interface{}, *rpcpkg.Error) { - return svc.Snapshot(ctx, req) - }) - rpcpkg.RegisterJSON(reg, "subagent.get", func(ctx context.Context, req rpcpkg.GetSubagentRequest) (interface{}, *rpcpkg.Error) { - return svc.Get(ctx, req) - }) - rpcpkg.RegisterJSON(reg, "subagent.spawn", func(ctx context.Context, req rpcpkg.SpawnSubagentRequest) (interface{}, *rpcpkg.Error) { - return svc.Spawn(ctx, req) - }) - rpcpkg.RegisterJSON(reg, "subagent.dispatch_and_wait", func(ctx context.Context, req rpcpkg.DispatchAndWaitRequest) (interface{}, *rpcpkg.Error) { - return svc.DispatchAndWait(ctx, req) - }) - rpcpkg.RegisterJSON(reg, "subagent.registry", func(ctx context.Context, req rpcpkg.RegistryRequest) (interface{}, *rpcpkg.Error) { - return svc.Registry(ctx, req) - }) - return reg -} - -func (s *Server) subagentRPCRegistry() *rpcpkg.Registry { - if s == nil { - return rpcpkg.NewRegistry() - } - s.subagentRPCOnce.Do(func() { - s.subagentRPCReg = s.buildSubagentRegistry() - }) - if s.subagentRPCReg == nil { - return rpcpkg.NewRegistry() - } - return s.subagentRPCReg -} - func (s *Server) buildNodeRegistry() *rpcpkg.Registry { svc := s.nodeRPCService() reg := rpcpkg.NewRegistry() @@ -298,104 +256,6 @@ func writeRPCError(w http.ResponseWriter, status int, requestID string, rpcErr * }) } -func (s *Server) handleSubagentLegacyAction(ctx context.Context, action string, args map[string]interface{}) (interface{}, *rpcpkg.Error) { - registry := s.subagentRPCRegistry() - req := rpcpkg.Request{ - Method: legacySubagentActionMethod(action), - Params: mustJSONMarshal(mapSubagentLegacyArgs(action, args)), - } - result, rpcErr := registry.Handle(ctx, req) - if rpcErr != nil && !strings.HasPrefix(strings.TrimSpace(req.Method), "subagent.") { - if s.onSubagents == nil { - return nil, rpcError("unavailable", "subagent runtime handler not configured", nil, false) - } - fallback, err := s.onSubagents(ctx, action, args) - if err != nil { - return nil, rpcErrorFrom(err) - } - return fallback, nil - } - return result, rpcErr -} - -var legacySubagentActionMethods = map[string]string{ - "": "subagent.list", - "list": "subagent.list", - "snapshot": "subagent.snapshot", - "get": "subagent.get", - "info": "subagent.get", - "spawn": "subagent.spawn", - "create": "subagent.spawn", - "dispatch_and_wait": "subagent.dispatch_and_wait", - "registry": "subagent.registry", -} - -func legacySubagentActionMethod(action string) string { - normalized := strings.ToLower(strings.TrimSpace(action)) - if method, ok := legacySubagentActionMethods[normalized]; ok { - return method - } - return strings.TrimSpace(action) -} - -var legacySubagentArgMappers = map[string]func(map[string]interface{}) interface{}{ - "snapshot": func(args map[string]interface{}) interface{} { - return rpcpkg.SnapshotRequest{Limit: tools.MapIntArg(args, "limit", 0)} - }, - "get": func(args map[string]interface{}) interface{} { - return rpcpkg.GetSubagentRequest{ID: tools.MapStringArg(args, "id")} - }, - "info": func(args map[string]interface{}) interface{} { - return rpcpkg.GetSubagentRequest{ID: tools.MapStringArg(args, "id")} - }, - "spawn": buildLegacySpawnSubagentRequest, - "create": func(args map[string]interface{}) interface{} { - return buildLegacySpawnSubagentRequest(args) - }, - "dispatch_and_wait": func(args map[string]interface{}) interface{} { - return rpcpkg.DispatchAndWaitRequest{ - Task: tools.MapStringArg(args, "task"), - Label: tools.MapStringArg(args, "label"), - Role: tools.MapStringArg(args, "role"), - AgentID: tools.MapStringArg(args, "agent_id"), - ThreadID: tools.MapStringArg(args, "thread_id"), - CorrelationID: tools.MapStringArg(args, "correlation_id"), - ParentRunID: tools.MapStringArg(args, "parent_run_id"), - MaxRetries: tools.MapIntArg(args, "max_retries", 0), - RetryBackoffMS: tools.MapIntArg(args, "retry_backoff_ms", 0), - TimeoutSec: tools.MapIntArg(args, "timeout_sec", 0), - MaxTaskChars: tools.MapIntArg(args, "max_task_chars", 0), - MaxResultChars: tools.MapIntArg(args, "max_result_chars", 0), - WaitTimeoutSec: tools.MapIntArg(args, "wait_timeout_sec", 0), - Channel: firstNonEmptyString(tools.MapStringArg(args, "channel"), tools.MapStringArg(args, "origin_channel")), - ChatID: firstNonEmptyString(tools.MapStringArg(args, "chat_id"), tools.MapStringArg(args, "origin_chat_id")), - } - }, -} - -func mapSubagentLegacyArgs(action string, args map[string]interface{}) interface{} { - normalized := strings.ToLower(strings.TrimSpace(action)) - if mapper, ok := legacySubagentArgMappers[normalized]; ok && mapper != nil { - return mapper(args) - } - return args -} - -func buildLegacySpawnSubagentRequest(args map[string]interface{}) interface{} { - return rpcpkg.SpawnSubagentRequest{ - Task: tools.MapStringArg(args, "task"), - Label: tools.MapStringArg(args, "label"), - Role: tools.MapStringArg(args, "role"), - AgentID: tools.MapStringArg(args, "agent_id"), - MaxRetries: tools.MapIntArg(args, "max_retries", 0), - RetryBackoffMS: tools.MapIntArg(args, "retry_backoff_ms", 0), - TimeoutSec: tools.MapIntArg(args, "timeout_sec", 0), - MaxTaskChars: tools.MapIntArg(args, "max_task_chars", 0), - MaxResultChars: tools.MapIntArg(args, "max_result_chars", 0), - Channel: firstNonEmptyString(tools.MapStringArg(args, "channel"), tools.MapStringArg(args, "origin_channel")), - ChatID: firstNonEmptyString(tools.MapStringArg(args, "chat_id"), tools.MapStringArg(args, "origin_chat_id")), - } -} func mustJSONMarshal(value interface{}) json.RawMessage { if value == nil { diff --git a/pkg/api/rpc_services.go b/pkg/api/rpc_services.go index 3b8a7e4..14a2923 100644 --- a/pkg/api/rpc_services.go +++ b/pkg/api/rpc_services.go @@ -26,157 +26,6 @@ func mustPrettyJSON(v interface{}) []byte { return out } -type subagentRPCAdapter struct { - server *Server -} - -func (a *subagentRPCAdapter) call(ctx context.Context, action string, args map[string]interface{}) (interface{}, *rpcpkg.Error) { - if a == nil || a.server == nil || a.server.onSubagents == nil { - return nil, rpcError("unavailable", "subagent runtime handler not configured", nil, false) - } - result, err := a.server.onSubagents(ctx, action, args) - if err != nil { - return nil, rpcErrorFrom(err) - } - return result, nil -} - -func (a *subagentRPCAdapter) List(ctx context.Context, _ rpcpkg.ListSubagentsRequest) (*rpcpkg.ListSubagentsResponse, *rpcpkg.Error) { - result, rpcErr := a.call(ctx, "list", nil) - if rpcErr != nil { - return nil, rpcErr - } - var payload struct { - Items []*map[string]interface{} `json:"items"` - } - items, err := decodeResultSliceField[resultWrapperSubagentTask](result, "items") - if err != nil { - return nil, rpcError("internal", err.Error(), nil, false) - } - _ = payload - out := make([]*resultWrapperSubagentTask, 0, len(items)) - for _, item := range items { - if item != nil { - out = append(out, item) - } - } - return &rpcpkg.ListSubagentsResponse{Items: unwrapSubagentTasks(out)}, nil -} - -func (a *subagentRPCAdapter) Snapshot(ctx context.Context, req rpcpkg.SnapshotRequest) (*rpcpkg.SnapshotResponse, *rpcpkg.Error) { - result, rpcErr := a.call(ctx, "snapshot", map[string]interface{}{"limit": req.Limit}) - if rpcErr != nil { - return nil, rpcErr - } - var snapshot struct { - Snapshot json.RawMessage `json:"snapshot"` - } - if err := decodeResultObject(result, &snapshot); err != nil { - return nil, rpcError("internal", err.Error(), nil, false) - } - var out rpcpkg.SnapshotResponse - if len(snapshot.Snapshot) > 0 { - if err := json.Unmarshal(snapshot.Snapshot, &out.Snapshot); err != nil { - return nil, rpcError("internal", err.Error(), nil, false) - } - } - return &out, nil -} - -func (a *subagentRPCAdapter) Get(ctx context.Context, req rpcpkg.GetSubagentRequest) (*rpcpkg.GetSubagentResponse, *rpcpkg.Error) { - result, rpcErr := a.call(ctx, "get", map[string]interface{}{"id": req.ID}) - if rpcErr != nil { - return nil, rpcErr - } - var payload struct { - Found bool `json:"found"` - Task *resultWrapperSubagentTask `json:"task"` - } - if err := decodeResultObject(result, &payload); err != nil { - return nil, rpcError("internal", err.Error(), nil, false) - } - return &rpcpkg.GetSubagentResponse{Found: payload.Found, Task: unwrapSubagentTask(payload.Task)}, nil -} - -func (a *subagentRPCAdapter) Spawn(ctx context.Context, req rpcpkg.SpawnSubagentRequest) (*rpcpkg.SpawnSubagentResponse, *rpcpkg.Error) { - result, rpcErr := a.call(ctx, "spawn", map[string]interface{}{ - "task": req.Task, - "label": req.Label, - "role": req.Role, - "agent_id": req.AgentID, - "max_retries": req.MaxRetries, - "retry_backoff_ms": req.RetryBackoffMS, - "timeout_sec": req.TimeoutSec, - "max_task_chars": req.MaxTaskChars, - "max_result_chars": req.MaxResultChars, - "channel": req.Channel, - "chat_id": req.ChatID, - }) - if rpcErr != nil { - return nil, rpcErr - } - var payload rpcpkg.SpawnSubagentResponse - if err := decodeResultObject(result, &payload); err != nil { - return nil, rpcError("internal", err.Error(), nil, false) - } - return &payload, nil -} - -func (a *subagentRPCAdapter) DispatchAndWait(ctx context.Context, req rpcpkg.DispatchAndWaitRequest) (*rpcpkg.DispatchAndWaitResponse, *rpcpkg.Error) { - result, rpcErr := a.call(ctx, "dispatch_and_wait", map[string]interface{}{ - "task": req.Task, - "label": req.Label, - "role": req.Role, - "agent_id": req.AgentID, - "thread_id": req.ThreadID, - "correlation_id": req.CorrelationID, - "parent_run_id": req.ParentRunID, - "channel": req.Channel, - "chat_id": req.ChatID, - "max_retries": req.MaxRetries, - "retry_backoff_ms": req.RetryBackoffMS, - "timeout_sec": req.TimeoutSec, - "max_task_chars": req.MaxTaskChars, - "max_result_chars": req.MaxResultChars, - "wait_timeout_sec": req.WaitTimeoutSec, - }) - if rpcErr != nil { - return nil, rpcErr - } - var payload struct { - Task *resultWrapperSubagentTask `json:"task"` - Reply json.RawMessage `json:"reply"` - Merged string `json:"merged"` - } - if err := decodeResultObject(result, &payload); err != nil { - return nil, rpcError("internal", err.Error(), nil, false) - } - out := &rpcpkg.DispatchAndWaitResponse{ - Task: unwrapSubagentTask(payload.Task), - Merged: payload.Merged, - } - if len(payload.Reply) > 0 { - var reply resultWrapperRouterReply - if err := json.Unmarshal(payload.Reply, &reply); err != nil { - return nil, rpcError("internal", err.Error(), nil, false) - } - out.Reply = unwrapRouterReply(&reply) - } - return out, nil -} - -func (a *subagentRPCAdapter) Registry(ctx context.Context, _ rpcpkg.RegistryRequest) (*rpcpkg.RegistryResponse, *rpcpkg.Error) { - result, rpcErr := a.call(ctx, "registry", nil) - if rpcErr != nil { - return nil, rpcErr - } - var payload rpcpkg.RegistryResponse - if err := decodeResultObject(result, &payload); err != nil { - return nil, rpcError("internal", err.Error(), nil, false) - } - return &payload, nil -} - type nodeRPCAdapter struct { server *Server } @@ -397,10 +246,6 @@ func (a *nodeRPCAdapter) PruneArtifacts(_ context.Context, req rpcpkg.PruneNodeA }}, nil } -func (s *Server) subagentRPCService() rpcpkg.SubagentService { - return &subagentRPCAdapter{server: s} -} - func (s *Server) nodeRPCService() rpcpkg.NodeService { return &nodeRPCAdapter{server: s} } @@ -1238,91 +1083,6 @@ func decodeResultSliceField[T any](result interface{}, field string) ([]*T, erro return items, nil } -type resultWrapperSubagentTask struct { - ID string `json:"id"` - Task string `json:"task"` - Label string `json:"label"` - Role string `json:"role"` - AgentID string `json:"agent_id"` - Transport string `json:"transport,omitempty"` - NodeID string `json:"node_id,omitempty"` - ParentAgentID string `json:"parent_agent_id,omitempty"` - NotifyMainPolicy string `json:"notify_main_policy,omitempty"` - SessionKey string `json:"session_key"` - MemoryNS string `json:"memory_ns"` - SystemPromptFile string `json:"system_prompt_file,omitempty"` - ToolAllowlist []string `json:"tool_allowlist,omitempty"` - MaxRetries int `json:"max_retries,omitempty"` - RetryBackoff int `json:"retry_backoff,omitempty"` - TimeoutSec int `json:"timeout_sec,omitempty"` - MaxTaskChars int `json:"max_task_chars,omitempty"` - MaxResultChars int `json:"max_result_chars,omitempty"` - RetryCount int `json:"retry_count,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` - ParentRunID string `json:"parent_run_id,omitempty"` - LastMessageID string `json:"last_message_id,omitempty"` - WaitingReply bool `json:"waiting_for_reply,omitempty"` - SharedState map[string]interface{} `json:"shared_state,omitempty"` - OriginChannel string `json:"origin_channel,omitempty"` - OriginChatID string `json:"origin_chat_id,omitempty"` - Status string `json:"status"` - Result string `json:"result,omitempty"` - Steering []string `json:"steering,omitempty"` - Created int64 `json:"created"` - Updated int64 `json:"updated"` -} - -func unwrapSubagentTask(in *resultWrapperSubagentTask) *tools.SubagentTask { - if in == nil { - return nil - } - return &tools.SubagentTask{ - ID: in.ID, - Task: in.Task, - Label: in.Label, - Role: in.Role, - AgentID: in.AgentID, - Transport: in.Transport, - NodeID: in.NodeID, - ParentAgentID: in.ParentAgentID, - NotifyMainPolicy: in.NotifyMainPolicy, - SessionKey: in.SessionKey, - MemoryNS: in.MemoryNS, - SystemPromptFile: in.SystemPromptFile, - ToolAllowlist: append([]string(nil), in.ToolAllowlist...), - MaxRetries: in.MaxRetries, - RetryBackoff: in.RetryBackoff, - TimeoutSec: in.TimeoutSec, - MaxTaskChars: in.MaxTaskChars, - MaxResultChars: in.MaxResultChars, - RetryCount: in.RetryCount, - ThreadID: in.ThreadID, - CorrelationID: in.CorrelationID, - ParentRunID: in.ParentRunID, - LastMessageID: in.LastMessageID, - WaitingReply: in.WaitingReply, - SharedState: in.SharedState, - OriginChannel: in.OriginChannel, - OriginChatID: in.OriginChatID, - Status: in.Status, - Result: in.Result, - Steering: append([]string(nil), in.Steering...), - Created: in.Created, - Updated: in.Updated, - } -} - -func unwrapSubagentTasks(in []*resultWrapperSubagentTask) []*tools.SubagentTask { - out := make([]*tools.SubagentTask, 0, len(in)) - for _, item := range in { - if task := unwrapSubagentTask(item); task != nil { - out = append(out, task) - } - } - return out -} - type resultWrapperRuntimeError struct { Code string `json:"code,omitempty"` Message string `json:"message,omitempty"` @@ -1332,30 +1092,16 @@ type resultWrapperRuntimeError struct { } type resultWrapperRunRecord struct { - ID string `json:"id"` - TaskID string `json:"task_id,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` - AgentID string `json:"agent_id,omitempty"` - ParentRunID string `json:"parent_run_id,omitempty"` - Kind string `json:"kind,omitempty"` - Status string `json:"status"` - Input string `json:"input,omitempty"` - Output string `json:"output,omitempty"` - Error *resultWrapperRuntimeError `json:"error,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` -} - -type resultWrapperRouterReply struct { - TaskID string `json:"task_id"` - ThreadID string `json:"thread_id,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` - AgentID string `json:"agent_id,omitempty"` - Status string `json:"status"` - Result string `json:"result,omitempty"` - Run resultWrapperRunRecord `json:"run"` - Error *resultWrapperRuntimeError `json:"error,omitempty"` + ID string `json:"id"` + TaskID string `json:"task_id,omitempty"` + AgentID string `json:"agent_id,omitempty"` + Kind string `json:"kind,omitempty"` + Status string `json:"status"` + Input string `json:"input,omitempty"` + Output string `json:"output,omitempty"` + Error *resultWrapperRuntimeError `json:"error,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } func unwrapRuntimeError(in *resultWrapperRuntimeError) *tools.RuntimeError { @@ -1373,35 +1119,16 @@ func unwrapRuntimeError(in *resultWrapperRuntimeError) *tools.RuntimeError { func unwrapRunRecord(in resultWrapperRunRecord) tools.RunRecord { return tools.RunRecord{ - ID: in.ID, - TaskID: in.TaskID, - ThreadID: in.ThreadID, - CorrelationID: in.CorrelationID, - AgentID: in.AgentID, - ParentRunID: in.ParentRunID, - Kind: in.Kind, - Status: in.Status, - Input: in.Input, - Output: in.Output, - Error: unwrapRuntimeError(in.Error), - CreatedAt: in.CreatedAt, - UpdatedAt: in.UpdatedAt, - } -} - -func unwrapRouterReply(in *resultWrapperRouterReply) *tools.RouterReply { - if in == nil { - return nil - } - return &tools.RouterReply{ - TaskID: in.TaskID, - ThreadID: in.ThreadID, - CorrelationID: in.CorrelationID, - AgentID: in.AgentID, - Status: in.Status, - Result: in.Result, - Run: unwrapRunRecord(in.Run), - Error: unwrapRuntimeError(in.Error), + ID: in.ID, + TaskID: in.TaskID, + AgentID: in.AgentID, + Kind: in.Kind, + Status: in.Status, + Input: in.Input, + Output: in.Output, + Error: unwrapRuntimeError(in.Error), + CreatedAt: in.CreatedAt, + UpdatedAt: in.UpdatedAt, } } diff --git a/pkg/api/server.go b/pkg/api/server.go index 4be7156..eb90fac 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -40,7 +40,7 @@ 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) + onRuntimeAdmin 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 @@ -58,8 +58,6 @@ type Server struct { oauthFlows map[string]*providers.OAuthPendingFlow extraRoutesMu sync.RWMutex extraRoutes map[string]http.Handler - subagentRPCOnce sync.Once - subagentRPCReg *rpcpkg.Registry nodeRPCOnce sync.Once nodeRPCReg *rpcpkg.Registry providerRPCOnce sync.Once @@ -109,8 +107,8 @@ 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) SetRuntimeAdminHandler(fn func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error)) { + s.onRuntimeAdmin = fn } func (s *Server) SetNodeDispatchHandler(fn func(ctx context.Context, req nodes.Request, mode string) (nodes.Response, error)) { s.onNodeDispatch = fn @@ -211,11 +209,13 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/nodes/heartbeat", s.handleHeartbeat) mux.HandleFunc("/nodes/connect", s.handleNodeConnect) mux.HandleFunc("/", s.handleWebUIAsset) + mux.HandleFunc("/api/auth/session", s.handleWebUIAuthSession) 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/world", s.handleWebUIWorld) mux.HandleFunc("/api/version", s.handleWebUIVersion) mux.HandleFunc("/api/provider/oauth/start", s.handleWebUIProviderOAuthStart) mux.HandleFunc("/api/provider/oauth/complete", s.handleWebUIProviderOAuthComplete) @@ -240,14 +240,13 @@ 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/rpc/subagent", s.handleSubagentRPC) mux.HandleFunc("/api/rpc/node", s.handleNodeRPC) mux.HandleFunc("/api/rpc/provider", s.handleProviderRPC) mux.HandleFunc("/api/rpc/workspace", s.handleWorkspaceRPC) mux.HandleFunc("/api/rpc/config", s.handleConfigRPC) mux.HandleFunc("/api/rpc/cron", s.handleCronRPC) mux.HandleFunc("/api/rpc/skills", s.handleSkillsRPC) - mux.HandleFunc("/api/subagents_runtime", s.handleWebUISubagentsRuntime) + mux.HandleFunc("/api/runtime_admin", s.handleWebUIRuntimeAdmin) mux.HandleFunc("/api/tool_allowlist_groups", s.handleWebUIToolAllowlistGroups) mux.HandleFunc("/api/tools", s.handleWebUITools) mux.HandleFunc("/api/mcp/install", s.handleWebUIMCPInstall) @@ -312,6 +311,38 @@ func canonicalOriginHost(host string, https bool) string { return strings.ToLower(net.JoinHostPort(host, port)) } +func normalizeOrigin(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil { + return "" + } + scheme := strings.ToLower(strings.TrimSpace(u.Scheme)) + switch scheme { + case "http", "https": + default: + return "" + } + if strings.TrimSpace(u.Host) == "" { + return "" + } + return scheme + "://" + canonicalOriginHost(u.Host, scheme == "https") +} + +func requestOrigin(r *http.Request) string { + if r == nil { + return "" + } + scheme := "http" + if requestUsesTLS(r) { + scheme = "https" + } + return scheme + "://" + canonicalOriginHost(r.Host, scheme == "https") +} + func (s *Server) isTrustedOrigin(r *http.Request) bool { if r == nil { return false @@ -320,16 +351,16 @@ func (s *Server) isTrustedOrigin(r *http.Request) bool { if origin == "" { return true } - u, err := url.Parse(origin) - if err != nil { + normalizedOrigin := normalizeOrigin(origin) + if normalizedOrigin == "" { return false } - switch strings.ToLower(strings.TrimSpace(u.Scheme)) { - case "http", "https": - default: - return false - } - return canonicalOriginHost(u.Host, strings.EqualFold(u.Scheme, "https")) == canonicalOriginHost(r.Host, requestUsesTLS(r)) + return true +} + +func (s *Server) shouldUseCrossSiteCookie(r *http.Request) bool { + origin := normalizeOrigin(r.Header.Get("Origin")) + return origin != "" && origin != requestOrigin(r) && s.isTrustedOrigin(r) } func (s *Server) websocketUpgrader() *websocket.Upgrader { diff --git a/pkg/api/server_common_helpers.go b/pkg/api/server_common_helpers.go index ca358a2..c0eacbb 100644 --- a/pkg/api/server_common_helpers.go +++ b/pkg/api/server_common_helpers.go @@ -85,12 +85,6 @@ func (s *Server) memoryFilePath(name string) string { func buildAgentTreeRoot(nodeID string, items []map[string]interface{}) map[string]interface{} { rootID := "main" - for _, item := range items { - if strings.TrimSpace(stringFromMap(item, "type")) == "router" && strings.TrimSpace(stringFromMap(item, "agent_id")) != "" { - rootID = strings.TrimSpace(stringFromMap(item, "agent_id")) - break - } - } nodesByID := make(map[string]map[string]interface{}, len(items)+1) for _, item := range items { id := strings.TrimSpace(stringFromMap(item, "agent_id")) @@ -116,7 +110,7 @@ func buildAgentTreeRoot(nodeID string, items []map[string]interface{}) map[strin "agent_id": rootID, "display_name": "Main Agent", "role": "orchestrator", - "type": "router", + "type": "agent", "transport": "local", "managed_by": "derived", "enabled": true, diff --git a/pkg/api/server_config_test.go b/pkg/api/server_config_test.go index 148f4e2..dcecece 100644 --- a/pkg/api/server_config_test.go +++ b/pkg/api/server_config_test.go @@ -263,10 +263,10 @@ func TestHandleWebUIConfigNormalizedGet(t *testing.T) { cfgPath := filepath.Join(tmp, "config.json") cfg := cfgpkg.DefaultConfig() cfg.Logging.Enabled = false - cfg.Agents.Subagents["coder"] = cfgpkg.SubagentConfig{ - Enabled: true, - Role: "coding", - SystemPromptFile: "agents/coder/AGENT.md", + cfg.Agents.Agents["coder"] = cfgpkg.AgentConfig{ + Enabled: true, + Role: "coding", + PromptFile: "agents/coder/AGENT.md", } if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { t.Fatalf("save config: %v", err) @@ -313,7 +313,7 @@ func TestHandleWebUIConfigNormalizedPost(t *testing.T) { "default_provider": "openai", "default_model": "gpt-5.4", "main_agent_id": "main", - "subagents": map[string]interface{}{ + "agents": map[string]interface{}{ "reviewer": map[string]interface{}{ "enabled": true, "role": "testing", @@ -327,18 +327,6 @@ func TestHandleWebUIConfigNormalizedPost(t *testing.T) { "gateway": map[string]interface{}{"host": "127.0.0.1", "port": float64(18790)}, }, "runtime": map[string]interface{}{ - "router": map[string]interface{}{ - "enabled": true, - "strategy": "rules_first", - "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"}}, - }, - }, "providers": map[string]interface{}{ "openai": map[string]interface{}{ "auth": "bearer", @@ -369,10 +357,7 @@ func TestHandleWebUIConfigNormalizedPost(t *testing.T) { 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 _, ok := loaded.Agents.Agents["reviewer"]; !ok { + t.Fatalf("expected reviewer agent, got %+v", loaded.Agents.Agents) } } diff --git a/pkg/api/server_observability.go b/pkg/api/server_observability.go index 0a791bc..fec7707 100644 --- a/pkg/api/server_observability.go +++ b/pkg/api/server_observability.go @@ -313,7 +313,7 @@ func isUserFacingSessionKey(key string) bool { return false } switch { - case strings.HasPrefix(k, "subagent:"): + case strings.HasPrefix(k, "agent:"): return false case strings.HasPrefix(k, "internal:"): return false diff --git a/pkg/api/server_rpc_facades.go b/pkg/api/server_rpc_facades.go index 565385f..8672476 100644 --- a/pkg/api/server_rpc_facades.go +++ b/pkg/api/server_rpc_facades.go @@ -367,13 +367,13 @@ func (s *Server) handleWebUICron(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) handleWebUISubagentsRuntime(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUIRuntimeAdmin(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) + if s.onRuntimeAdmin == nil { + http.Error(w, "runtime admin handler not configured", http.StatusServiceUnavailable) return } @@ -411,9 +411,9 @@ func (s *Server) handleWebUISubagentsRuntime(w http.ResponseWriter, r *http.Requ return } - result, rpcErr := s.handleSubagentLegacyAction(r.Context(), action, args) - if rpcErr != nil { - http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr)) + result, err := s.onRuntimeAdmin(r.Context(), action, args) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, map[string]interface{}{"ok": true, "result": result}) diff --git a/pkg/api/server_rpc_test.go b/pkg/api/server_rpc_test.go index 4c48df9..0c18e56 100644 --- a/pkg/api/server_rpc_test.go +++ b/pkg/api/server_rpc_test.go @@ -2,7 +2,6 @@ package api import ( "context" - "fmt" "net/http" "net/http/httptest" "os" @@ -14,34 +13,6 @@ import ( "github.com/YspCoder/clawgo/pkg/nodes" ) -func TestHandleSubagentRPCSpawn(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 != "spawn" { - t.Fatalf("unexpected action: %s", action) - } - if fmt.Sprint(args["agent_id"]) != "coder" || fmt.Sprint(args["task"]) != "ship it" { - t.Fatalf("unexpected args: %+v", args) - } - return map[string]interface{}{"message": "spawned"}, nil - }) - - body := `{"method":"subagent.spawn","request_id":"req-1","params":{"agent_id":"coder","task":"ship it","channel":"webui","chat_id":"group"}}` - req := httptest.NewRequest(http.MethodPost, "/api/rpc/subagent", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - srv.handleSubagentRPC(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), `"request_id":"req-1"`) || !strings.Contains(rec.Body.String(), `"message":"spawned"`) { - t.Fatalf("unexpected rpc body: %s", rec.Body.String()) - } -} - func TestHandleNodeRPCDispatch(t *testing.T) { t.Parallel() diff --git a/pkg/api/server_runtime_nodes.go b/pkg/api/server_runtime_nodes.go index 793bf14..ba6176b 100644 --- a/pkg/api/server_runtime_nodes.go +++ b/pkg/api/server_runtime_nodes.go @@ -69,10 +69,12 @@ func (s *Server) buildWebUIRuntimeSnapshot(ctx context.Context) map[string]inter 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 { + worldPayload := map[string]interface{}{} + if s.onRuntimeAdmin != nil { + if res, err := s.onRuntimeAdmin(ctx, "snapshot", map[string]interface{}{"limit": 200}); err == nil { if m, ok := res.(map[string]interface{}); ok { runtimePayload = m + worldPayload = extractWorldPayloadFromRuntime(m) } } } @@ -80,6 +82,7 @@ func (s *Server) buildWebUIRuntimeSnapshot(ctx context.Context) map[string]inter "version": s.webUIVersionPayload(), "config": normalizedConfig, "runtime": runtimePayload, + "world": worldPayload, "nodes": s.webUINodesPayload(ctx), "sessions": s.webUISessionsPayload(), "task_queue": s.webUITaskQueuePayload(false), @@ -88,6 +91,80 @@ func (s *Server) buildWebUIRuntimeSnapshot(ctx context.Context) map[string]inter } } +func (s *Server) handleWebUIWorld(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + payload := s.webUIWorldPayload(r.Context(), queryBoundedPositiveInt(r, "limit", 50, 500)) + writeJSON(w, payload) +} + +func (s *Server) webUIWorldPayload(ctx context.Context, limit int) map[string]interface{} { + if s == nil || s.onRuntimeAdmin == nil { + return map[string]interface{}{"found": false, "world": map[string]interface{}{}} + } + res, err := s.onRuntimeAdmin(ctx, "world_snapshot", map[string]interface{}{"limit": limit}) + if err == nil { + if m, ok := res.(map[string]interface{}); ok { + if snapshot, ok := m["snapshot"]; ok { + return map[string]interface{}{"found": true, "world": snapshot} + } + } + } + if res, err := s.onRuntimeAdmin(ctx, "snapshot", map[string]interface{}{"limit": limit}); err == nil { + if m, ok := res.(map[string]interface{}); ok { + world := extractWorldPayloadFromRuntime(m) + if len(world) > 0 { + return map[string]interface{}{"found": true, "world": world} + } + } + } + return map[string]interface{}{"found": false, "world": map[string]interface{}{}} +} + +func extractWorldPayloadFromRuntime(runtimePayload map[string]interface{}) map[string]interface{} { + if len(runtimePayload) == 0 { + return map[string]interface{}{} + } + raw, ok := runtimePayload["snapshot"] + if !ok || raw == nil { + return map[string]interface{}{} + } + switch snapshot := raw.(type) { + case tools.RuntimeSnapshot: + return worldPayloadAsMap(snapshot.World) + case *tools.RuntimeSnapshot: + if snapshot != nil { + return worldPayloadAsMap(snapshot.World) + } + case map[string]interface{}: + if worldRaw, ok := snapshot["world"]; ok { + return worldPayloadAsMap(worldRaw) + } + } + return map[string]interface{}{} +} + +func worldPayloadAsMap(raw interface{}) map[string]interface{} { + switch v := raw.(type) { + case nil: + return map[string]interface{}{} + case map[string]interface{}: + return v + default: + data, err := json.Marshal(v) + if err != nil { + return map[string]interface{}{} + } + out := map[string]interface{}{} + if err := json.Unmarshal(data, &out); err != nil { + return map[string]interface{}{} + } + return out + } +} + func (s *Server) webUIVersionPayload() map[string]interface{} { return map[string]interface{}{ "gateway_version": firstNonEmptyString(s.gatewayVersion, gatewayBuildVersion()), @@ -339,31 +416,18 @@ 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 { + if s == nil { return nil } - result, err := s.onSubagents(ctx, "registry", nil) - if err != nil { + _ = ctx + if strings.TrimSpace(s.configPath) == "" { return nil } - payload, ok := result.(map[string]interface{}) - if !ok { + cfg, err := cfgpkg.LoadConfig(strings.TrimSpace(s.configPath)) + if err != nil || cfg == nil { return nil } - if rawItems, ok := payload["items"].([]map[string]interface{}); ok { - return rawItems - } - list, ok := payload["items"].([]interface{}) - if !ok { - return nil - } - items := make([]map[string]interface{}, 0, len(list)) - for _, item := range list { - if row, ok := item.(map[string]interface{}); ok { - items = append(items, row) - } - } - return items + return buildRegistryItemsFromNormalizedConfig(cfg.NormalizedView()) } func (s *Server) fetchRemoteNodeRegistry(ctx context.Context, node nodes.NodeInfo) ([]map[string]interface{}, error) { @@ -408,7 +472,7 @@ func (s *Server) fetchRemoteNodeRegistryLegacy(ctx context.Context, node nodes.N if baseURL == "" { return nil, fmt.Errorf("node %s endpoint missing", strings.TrimSpace(node.ID)) } - reqURL := baseURL + "/api/subagents_runtime?action=registry" + reqURL := baseURL + "/api/runtime_admin?action=registry" if tok := strings.TrimSpace(node.Token); tok != "" { reqURL += "&token=" + url.QueryEscape(tok) } @@ -438,30 +502,29 @@ func (s *Server) fetchRemoteNodeRegistryLegacy(ctx context.Context, node nodes.N } func buildRegistryItemsFromNormalizedConfig(view cfgpkg.NormalizedConfig) []map[string]interface{} { - items := make([]map[string]interface{}, 0, len(view.Core.Subagents)) - for agentID, subcfg := range view.Core.Subagents { + items := make([]map[string]interface{}, 0, len(view.Core.Agents)) + for agentID, subcfg := range view.Core.Agents { if strings.TrimSpace(agentID) == "" { continue } items = append(items, map[string]interface{}{ "agent_id": agentID, "enabled": subcfg.Enabled, - "type": "subagent", + "type": "agent", "transport": fallbackString(strings.TrimSpace(subcfg.RuntimeClass), "local"), "node_id": "", "parent_agent_id": "", - "notify_main_policy": "final_only", "display_name": "", "role": strings.TrimSpace(subcfg.Role), "description": "", - "system_prompt_file": strings.TrimSpace(subcfg.Prompt), + "prompt_file": strings.TrimSpace(subcfg.Prompt), "prompt_file_found": false, "memory_namespace": "", "tool_allowlist": append([]string(nil), subcfg.ToolAllowlist...), "tool_visibility": map[string]interface{}{}, "effective_tools": []string{}, "inherited_tools": []string{}, - "routing_keywords": routeKeywordsForRegistry(view.Runtime.Router.Rules, agentID), + "routing_keywords": []string{}, "managed_by": "config.json", }) } @@ -471,16 +534,6 @@ func buildRegistryItemsFromNormalizedConfig(view cfgpkg.NormalizedConfig) []map[ return items } -func routeKeywordsForRegistry(rules []cfgpkg.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 nodeWebUIBaseURL(node nodes.NodeInfo) string { endpoint := strings.TrimSpace(node.Endpoint) if endpoint == "" || strings.EqualFold(endpoint, "gateway") { diff --git a/pkg/api/server_security_test.go b/pkg/api/server_security_test.go index 003f67d..1027c24 100644 --- a/pkg/api/server_security_test.go +++ b/pkg/api/server_security_test.go @@ -42,13 +42,13 @@ func TestCheckAuthAllowsBearerAndCookieOnly(t *testing.T) { } } -func TestWithCORSRejectsForeignOrigin(t *testing.T) { +func TestWithCORSRejectsInvalidOrigin(t *testing.T) { t.Parallel() srv := NewServer("127.0.0.1", 0, "", nil) req := httptest.NewRequest(http.MethodGet, "http://example.com/api/config", nil) req.Host = "example.com" - req.Header.Set("Origin", "https://evil.example") + req.Header.Set("Origin", "javascript:alert(1)") rec := httptest.NewRecorder() srv.withCORS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -81,7 +81,32 @@ func TestWithCORSAcceptsSameOrigin(t *testing.T) { } } -func TestHandleNodeConnectRejectsForeignOrigin(t *testing.T) { +func TestWithCORSAcceptsCrossOrigin(t *testing.T) { + t.Parallel() + + srv := NewServer("127.0.0.1", 0, "", nil) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/api/config", nil) + req.Host = "example.com" + req.Header.Set("Origin", "https://web.example") + rec := httptest.NewRecorder() + + srv.withCORS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://web.example" { + t.Fatalf("unexpected allow-origin header %q", got) + } + if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Fatalf("expected allow credentials, got %q", got) + } +} + +func TestHandleNodeConnectRejectsInvalidOrigin(t *testing.T) { t.Parallel() srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) @@ -92,7 +117,7 @@ func TestHandleNodeConnectRejectsForeignOrigin(t *testing.T) { wsURL := "ws" + strings.TrimPrefix(httpSrv.URL, "http") + "/nodes/connect" dialer := websocket.Dialer{} - headers := http.Header{"Origin": []string{"https://evil.example"}} + headers := http.Header{"Origin": []string{"javascript:alert(1)"}} conn, resp, err := dialer.Dial(wsURL, headers) if err == nil { conn.Close() @@ -103,6 +128,25 @@ func TestHandleNodeConnectRejectsForeignOrigin(t *testing.T) { } } +func TestHandleNodeConnectAcceptsCrossOrigin(t *testing.T) { + t.Parallel() + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + mux := http.NewServeMux() + mux.HandleFunc("/nodes/connect", srv.handleNodeConnect) + httpSrv := httptest.NewServer(mux) + defer httpSrv.Close() + + wsURL := "ws" + strings.TrimPrefix(httpSrv.URL, "http") + "/nodes/connect" + dialer := websocket.Dialer{} + headers := http.Header{"Origin": []string{"https://web.example"}} + conn, resp, err := dialer.Dial(wsURL, headers) + if err != nil { + t.Fatalf("expected websocket handshake to succeed, resp=%#v err=%v", resp, err) + } + _ = conn.Close() +} + func TestHandleWebUISetsCookieForBearerOnly(t *testing.T) { t.Parallel() @@ -125,6 +169,34 @@ func TestHandleWebUISetsCookieForBearerOnly(t *testing.T) { } } +func TestHandleWebUIAuthSessionSetsCrossSiteCookieForAllowedOrigin(t *testing.T) { + t.Parallel() + + srv := NewServer("127.0.0.1", 0, "secret-token", nil) + + req := httptest.NewRequest(http.MethodPost, "http://gateway.example/api/auth/session", nil) + req.Host = "gateway.example" + req.Header.Set("Origin", "https://web.example") + req.Header.Set("Authorization", "Bearer secret-token") + rec := httptest.NewRecorder() + + srv.withCORS(http.HandlerFunc(srv.handleWebUIAuthSession)).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + cookies := rec.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("expected one cookie, got %d", len(cookies)) + } + if cookies[0].SameSite != http.SameSiteNoneMode { + t.Fatalf("expected SameSite=None for cross-site session, got %v", cookies[0].SameSite) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://web.example" { + t.Fatalf("unexpected allow-origin header %q", got) + } +} + func TestHandleWebUIUploadDoesNotExposeAbsolutePath(t *testing.T) { t.Parallel() diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index 8a2c795..f7ce219 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + cfgpkg "github.com/YspCoder/clawgo/pkg/config" "github.com/YspCoder/clawgo/pkg/nodes" "github.com/gorilla/websocket" ) @@ -192,7 +193,7 @@ func TestHandleWebUISessionsHidesInternalSessionsByDefault(t *testing.T) { "internal:heartbeat.jsonl", "heartbeat:default.jsonl", "cron:nightly.jsonl", - "subagent:worker.jsonl", + "agent:coder.jsonl", } { if err := os.WriteFile(filepath.Join(sessionsDir, name), []byte("{}\n"), 0644); err != nil { t.Fatalf("write %s: %v", name, err) @@ -384,22 +385,16 @@ 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 - }) + cfgPath := filepath.Join(t.TempDir(), "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Agents.Agents["coder"] = cfgpkg.AgentConfig{ + Enabled: true, + Role: "coding", + } + 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() @@ -428,11 +423,85 @@ func TestHandleWebUINodesEnrichesLocalNodeMetadata(t *testing.T) { t.Fatalf("expected local actions, got %+v", local) } agents, _ := local["agents"].([]interface{}) - if len(agents) != 1 { + if len(agents) != 2 { t.Fatalf("expected local agents from registry, got %+v", local) } } +func TestBuildWebUIRuntimeSnapshotIncludesWorldPayload(t *testing.T) { + t.Parallel() + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + srv.SetRuntimeAdminHandler(func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) { + switch action { + case "snapshot": + return map[string]interface{}{ + "snapshot": map[string]interface{}{ + "world": map[string]interface{}{ + "world_id": "main-world", + "tick": 3, + "npc_count": 2, + "active_npcs": []string{"keeper", "merchant"}, + }, + }, + }, nil + case "world_snapshot": + return map[string]interface{}{ + "snapshot": map[string]interface{}{ + "world_id": "main-world", + "tick": 3, + "npc_count": 2, + }, + }, nil + default: + return map[string]interface{}{}, nil + } + }) + + payload := srv.buildWebUIRuntimeSnapshot(context.Background()) + worldPayload, _ := payload["world"].(map[string]interface{}) + if strings.TrimSpace(fmt.Sprint(worldPayload["world_id"])) != "main-world" { + t.Fatalf("expected top-level world payload, got %+v", payload) + } +} + +func TestHandleWebUIWorldReturnsWorldSnapshot(t *testing.T) { + t.Parallel() + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + srv.SetRuntimeAdminHandler(func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) { + if action != "world_snapshot" { + return map[string]interface{}{}, nil + } + return map[string]interface{}{ + "snapshot": map[string]interface{}{ + "world_id": "main-world", + "tick": 7, + "npc_count": 1, + "active_npcs": []string{"watcher"}, + }, + }, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/api/world?limit=10", nil) + rec := httptest.NewRecorder() + srv.handleWebUIWorld(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + var body map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if found, _ := body["found"].(bool); !found { + t.Fatalf("expected found=true, got %+v", body) + } + worldPayload, _ := body["world"].(map[string]interface{}) + if strings.TrimSpace(fmt.Sprint(worldPayload["tick"])) != "7" { + t.Fatalf("expected world tick 7, got %+v", body) + } +} + func TestHandleWebUINodeDispatchReplay(t *testing.T) { t.Parallel() diff --git a/pkg/api/server_webui.go b/pkg/api/server_webui.go index 341eb0a..42fddd8 100644 --- a/pkg/api/server_webui.go +++ b/pkg/api/server_webui.go @@ -11,6 +11,25 @@ import ( "time" ) +func (s *Server) setWebUISessionCookie(w http.ResponseWriter, r *http.Request) { + if s == nil || w == nil || strings.TrimSpace(s.token) == "" { + return + } + sameSite := http.SameSiteLaxMode + if s.shouldUseCrossSiteCookie(r) { + sameSite = http.SameSiteNoneMode + } + http.SetCookie(w, &http.Cookie{ + Name: "clawgo_webui_token", + Value: s.token, + Path: "/", + HttpOnly: true, + Secure: requestUsesTLS(r), + SameSite: sameSite, + MaxAge: 86400, + }) +} + func (s *Server) handleWebUI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -21,15 +40,7 @@ func (s *Server) handleWebUI(w http.ResponseWriter, r *http.Request) { return } if s.token != "" && s.isBearerAuthorized(r) { - http.SetCookie(w, &http.Cookie{ - Name: "clawgo_webui_token", - Value: s.token, - Path: "/", - HttpOnly: true, - Secure: requestUsesTLS(r), - SameSite: http.SameSiteLaxMode, - MaxAge: 86400, - }) + s.setWebUISessionCookie(w, r) } if s.tryServeWebUIDist(w, r, "/index.html") { return @@ -38,6 +49,19 @@ func (s *Server) handleWebUI(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(webUIHTML)) } +func (s *Server) handleWebUIAuthSession(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !s.isBearerAuthorized(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + s.setWebUISessionCookie(w, r) + writeJSON(w, map[string]interface{}{"ok": true}) +} + func (s *Server) handleWebUIAsset(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -154,18 +178,144 @@ func detectWebUIVersion(webUIDir string) string { const webUIHTML = ` ClawGo WebUI - + -

ClawGo WebUI

-

Token:

-

Config (dynamic + hot reload)

- - - -

Chat (supports media upload)

-
Session:
-
+
+
+
+

ClawGo World Console

+

主对è¯ã€ä»»åŠ¡ã€NPC 世界状æ€éƒ½åœ¨è¿™é‡Œã€‚当å‰é¡µé¢ç›´æŽ¥æ¶ˆè´¹ /api/runtime å’Œ /api/world。

+
+
Token:
+
+
+
+
+

Chat

+
+ Session: + + + + +
+
+
+
+

Config

+
+ + +
+ +
+
+
+
+

World

+
+ + +
+
+
World-
+
Tick-
+
NPC Count-
+
Quests-
+
+

Map

+
+ +
+ Location + Selected + Connection + NPC count + Entity count +
+
+

Location Detail

+
Click a location on the map to inspect it.
+
+

NPC Detail

+
Click an NPC in a location to inspect it.
+
+

Entity Detail

+
Click an entity in a location to inspect it.
+

Create Entity

+
+ + + + +
+
+ +
+

Create Quest

+
+ + + + + + + +
+
+ +
+

World Log

+
+

Quest Board

+
+

Occupancy

+
+

Snapshot

+

+    
+
+
+
` diff --git a/pkg/config/config.go b/pkg/config/config.go index fc4dbec..f6d7ac5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -30,66 +30,36 @@ type Config struct { } type AgentsConfig struct { - Defaults AgentDefaults `json:"defaults"` - Router AgentRouterConfig `json:"router,omitempty"` - Communication AgentCommunicationConfig `json:"communication,omitempty"` - Subagents map[string]SubagentConfig `json:"subagents,omitempty"` + Defaults AgentDefaults `json:"defaults"` + Agents map[string]AgentConfig `json:"agents,omitempty"` } -type AgentRouterConfig struct { - Enabled bool `json:"enabled"` - MainAgentID string `json:"main_agent_id,omitempty"` - Strategy string `json:"strategy,omitempty"` - Policy AgentRouterPolicyConfig `json:"policy,omitempty"` - Rules []AgentRouteRule `json:"rules,omitempty"` - 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"` +type AgentConfig struct { + Enabled bool `json:"enabled"` + Kind string `json:"kind,omitempty"` + Type string `json:"type,omitempty"` + Transport string `json:"transport,omitempty"` + NodeID string `json:"node_id,omitempty"` + ParentAgentID string `json:"parent_agent_id,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Role string `json:"role,omitempty"` + Description string `json:"description,omitempty"` + Persona string `json:"persona,omitempty"` + Traits []string `json:"traits,omitempty"` + Faction string `json:"faction,omitempty"` + HomeLocation string `json:"home_location,omitempty"` + DefaultGoals []string `json:"default_goals,omitempty"` + PerceptionScope int `json:"perception_scope,omitempty"` + ScheduleHint string `json:"schedule_hint,omitempty"` + WorldTags []string `json:"world_tags,omitempty"` + PromptFile string `json:"prompt_file,omitempty"` + MemoryNamespace string `json:"memory_namespace,omitempty"` + Tools AgentToolsConfig `json:"tools,omitempty"` + Runtime AgentRuntimeConfig `json:"runtime,omitempty"` } -type AgentRouterPolicyConfig struct { - IntentMaxInputChars int `json:"intent_max_input_chars" env:"CLAWGO_INTENT_MAX_INPUT_CHARS"` - MaxRoundsWithoutUser int `json:"max_rounds_without_user" env:"CLAWGO_AUTOLEARN_MAX_ROUNDS_WITHOUT_USER"` -} - -type AgentRouteRule struct { - AgentID string `json:"agent_id"` - Keywords []string `json:"keywords,omitempty"` -} - -type AgentCommunicationConfig struct { - Mode string `json:"mode,omitempty"` - PersistThreads bool `json:"persist_threads,omitempty"` - PersistMessages bool `json:"persist_messages,omitempty"` - MaxMessagesPerThread int `json:"max_messages_per_thread,omitempty"` - DeadLetterQueue bool `json:"dead_letter_queue,omitempty"` - DefaultMessageTTLSec int `json:"default_message_ttl_sec,omitempty"` -} - -type SubagentConfig struct { - Enabled bool `json:"enabled"` - Type string `json:"type,omitempty"` - Transport string `json:"transport,omitempty"` - NodeID string `json:"node_id,omitempty"` - ParentAgentID string `json:"parent_agent_id,omitempty"` - NotifyMainPolicy string `json:"notify_main_policy,omitempty"` - DisplayName string `json:"display_name,omitempty"` - Role string `json:"role,omitempty"` - Description string `json:"description,omitempty"` - SystemPromptFile string `json:"system_prompt_file,omitempty"` - MemoryNamespace string `json:"memory_namespace,omitempty"` - AcceptFrom []string `json:"accept_from,omitempty"` - CanTalkTo []string `json:"can_talk_to,omitempty"` - RequiresMainMediation bool `json:"requires_main_mediation,omitempty"` - DefaultReplyTo string `json:"default_reply_to,omitempty"` - Tools SubagentToolsConfig `json:"tools,omitempty"` - Runtime SubagentRuntimeConfig `json:"runtime,omitempty"` -} - -func (s *SubagentConfig) UnmarshalJSON(data []byte) error { - type alias SubagentConfig +func (s *AgentConfig) UnmarshalJSON(data []byte) error { + type alias AgentConfig var raw struct { alias LegacySystemPrompt string `json:"system_prompt"` @@ -97,17 +67,17 @@ func (s *SubagentConfig) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &raw); err != nil { return err } - *s = SubagentConfig(raw.alias) + *s = AgentConfig(raw.alias) return nil } -type SubagentToolsConfig struct { +type AgentToolsConfig struct { Allowlist []string `json:"allowlist,omitempty"` Denylist []string `json:"denylist,omitempty"` MaxParallelCalls int `json:"max_parallel_calls,omitempty"` } -type SubagentRuntimeConfig struct { +type AgentRuntimeConfig struct { Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` Temperature float64 `json:"temperature,omitempty"` @@ -478,30 +448,7 @@ func DefaultConfig() *Config { OutcomesTitle: "Execution Outcomes", }, }, - Router: AgentRouterConfig{ - Enabled: false, - MainAgentID: "main", - Strategy: "rules_first", - Policy: AgentRouterPolicyConfig{ - IntentMaxInputChars: 1200, - MaxRoundsWithoutUser: 200, - }, - Rules: []AgentRouteRule{}, - AllowDirectAgentChat: false, - MaxHops: 6, - DefaultTimeoutSec: 600, - DefaultWaitReply: true, - StickyThreadOwner: true, - }, - Communication: AgentCommunicationConfig{ - Mode: "mediated", - PersistThreads: true, - PersistMessages: true, - MaxMessagesPerThread: 100, - DeadLetterQueue: true, - DefaultMessageTTLSec: 86400, - }, - Subagents: map[string]SubagentConfig{}, + Agents: map[string]AgentConfig{}, }, Channels: ChannelsConfig{ InboundMessageIDDedupeTTLSeconds: 600, diff --git a/pkg/config/normalized.go b/pkg/config/normalized.go index 61cb163..8b3557a 100644 --- a/pkg/config/normalized.go +++ b/pkg/config/normalized.go @@ -8,15 +8,15 @@ type NormalizedConfig struct { } type NormalizedCoreConfig struct { - DefaultProvider string `json:"default_provider,omitempty"` - DefaultModel string `json:"default_model,omitempty"` - MainAgentID string `json:"main_agent_id,omitempty"` - Subagents map[string]NormalizedSubagentConfig `json:"subagents,omitempty"` - Tools NormalizedCoreToolsConfig `json:"tools,omitempty"` - Gateway NormalizedCoreGatewayConfig `json:"gateway,omitempty"` + DefaultProvider string `json:"default_provider,omitempty"` + DefaultModel string `json:"default_model,omitempty"` + MainAgentID string `json:"main_agent_id,omitempty"` + Agents map[string]NormalizedAgentConfig `json:"agents,omitempty"` + Tools NormalizedCoreToolsConfig `json:"tools,omitempty"` + Gateway NormalizedCoreGatewayConfig `json:"gateway,omitempty"` } -type NormalizedSubagentConfig struct { +type NormalizedAgentConfig struct { Enabled bool `json:"enabled"` Role string `json:"role,omitempty"` Prompt string `json:"prompt,omitempty"` @@ -36,21 +36,9 @@ type NormalizedCoreGatewayConfig struct { } type NormalizedRuntimeConfig struct { - Router NormalizedRuntimeRouterConfig `json:"router,omitempty"` Providers map[string]NormalizedRuntimeProviderConfig `json:"providers,omitempty"` } -type NormalizedRuntimeRouterConfig struct { - Enabled bool `json:"enabled"` - Strategy string `json:"strategy,omitempty"` - 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"` -} - type NormalizedRuntimeProviderConfig struct { Auth string `json:"auth,omitempty"` APIBase string `json:"api_base,omitempty"` @@ -66,33 +54,23 @@ func (c *Config) Normalize() { if c == nil { return } - if strings.TrimSpace(c.Agents.Router.MainAgentID) == "" { - c.Agents.Router.MainAgentID = "main" + if c.Agents.Agents == nil { + c.Agents.Agents = map[string]AgentConfig{} } - if c.Agents.Subagents == nil { - c.Agents.Subagents = map[string]SubagentConfig{} + main := c.Agents.Agents["main"] + if !main.Enabled { + main.Enabled = true } - if c.Agents.Router.Enabled { - mainID := strings.TrimSpace(c.Agents.Router.MainAgentID) - if mainID == "" { - mainID = "main" - c.Agents.Router.MainAgentID = mainID - } - main := c.Agents.Subagents[mainID] - if !main.Enabled { - main.Enabled = true - } - if strings.TrimSpace(main.Role) == "" { - main.Role = "orchestrator" - } - if strings.TrimSpace(main.Type) == "" { - main.Type = "router" - } - if strings.TrimSpace(main.SystemPromptFile) == "" { - main.SystemPromptFile = "agents/main/AGENT.md" - } - c.Agents.Subagents[mainID] = main + if strings.TrimSpace(main.Role) == "" { + main.Role = "orchestrator" } + if strings.TrimSpace(main.Type) == "" { + main.Type = "agent" + } + if strings.TrimSpace(main.PromptFile) == "" { + main.PromptFile = "agents/main/AGENT.md" + } + c.Agents.Agents["main"] = main if provider, model := ParseProviderModelRef(c.Agents.Defaults.Model.Primary); provider != "" && model != "" { c.Agents.Defaults.Model.Primary = provider + "/" + model } @@ -101,8 +79,8 @@ func (c *Config) Normalize() { func (c *Config) NormalizedView() NormalizedConfig { view := NormalizedConfig{ Core: NormalizedCoreConfig{ - MainAgentID: strings.TrimSpace(c.Agents.Router.MainAgentID), - Subagents: map[string]NormalizedSubagentConfig{}, + MainAgentID: "main", + Agents: map[string]NormalizedAgentConfig{}, Tools: NormalizedCoreToolsConfig{ ShellEnabled: c.Tools.Shell.Enabled, MCPEnabled: c.Tools.MCP.Enabled, @@ -113,16 +91,6 @@ func (c *Config) NormalizedView() NormalizedConfig { }, }, Runtime: NormalizedRuntimeConfig{ - Router: NormalizedRuntimeRouterConfig{ - Enabled: c.Agents.Router.Enabled, - Strategy: c.Agents.Router.Strategy, - 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...), - }, Providers: map[string]NormalizedRuntimeProviderConfig{}, }, } @@ -131,11 +99,11 @@ func (c *Config) NormalizedView() NormalizedConfig { view.Core.DefaultProvider = PrimaryProviderName(c) view.Core.DefaultModel = strings.TrimSpace(c.Agents.Defaults.Model.Primary) } - for id, subcfg := range c.Agents.Subagents { - view.Core.Subagents[id] = NormalizedSubagentConfig{ + for id, subcfg := range c.Agents.Agents { + view.Core.Agents[id] = NormalizedAgentConfig{ Enabled: subcfg.Enabled, Role: subcfg.Role, - Prompt: subcfg.SystemPromptFile, + Prompt: subcfg.PromptFile, Provider: subcfg.Runtime.Provider, ToolAllowlist: append([]string(nil), subcfg.Tools.Allowlist...), RuntimeClass: firstNonEmptyRuntimeClass(subcfg), @@ -156,7 +124,7 @@ func (c *Config) NormalizedView() NormalizedConfig { return view } -func firstNonEmptyRuntimeClass(subcfg SubagentConfig) string { +func firstNonEmptyRuntimeClass(subcfg AgentConfig) string { switch { case strings.TrimSpace(subcfg.Runtime.Provider) != "": return "provider_bound" @@ -176,9 +144,6 @@ func (c *Config) ApplyNormalizedView(view NormalizedConfig) { if defaultProvider != "" && defaultModel != "" { c.Agents.Defaults.Model.Primary = normalizeProviderNameAlias(defaultProvider) + "/" + defaultModel } - if strings.TrimSpace(view.Core.MainAgentID) != "" { - c.Agents.Router.MainAgentID = strings.TrimSpace(view.Core.MainAgentID) - } c.Tools.Shell.Enabled = view.Core.Tools.ShellEnabled c.Tools.MCP.Enabled = view.Core.Tools.MCPEnabled if strings.TrimSpace(view.Core.Gateway.Host) != "" { @@ -188,15 +153,15 @@ func (c *Config) ApplyNormalizedView(view NormalizedConfig) { c.Gateway.Port = view.Core.Gateway.Port } - nextSubagents := map[string]SubagentConfig{} - for id, current := range c.Agents.Subagents { - nextSubagents[id] = current + nextAgents := map[string]AgentConfig{} + for id, current := range c.Agents.Agents { + nextAgents[id] = current } - for id, item := range view.Core.Subagents { - current := c.Agents.Subagents[id] + for id, item := range view.Core.Agents { + current := c.Agents.Agents[id] current.Enabled = item.Enabled current.Role = strings.TrimSpace(item.Role) - current.SystemPromptFile = strings.TrimSpace(item.Prompt) + current.PromptFile = strings.TrimSpace(item.Prompt) current.Tools.Allowlist = append([]string(nil), item.ToolAllowlist...) current.Runtime.Provider = strings.TrimSpace(item.Provider) switch strings.TrimSpace(item.RuntimeClass) { @@ -208,24 +173,9 @@ func (c *Config) ApplyNormalizedView(view NormalizedConfig) { default: current.Transport = strings.TrimSpace(item.RuntimeClass) } - nextSubagents[id] = current + nextAgents[id] = current } - c.Agents.Subagents = nextSubagents - - c.Agents.Router.Enabled = view.Runtime.Router.Enabled - if strings.TrimSpace(view.Runtime.Router.Strategy) != "" { - c.Agents.Router.Strategy = strings.TrimSpace(view.Runtime.Router.Strategy) - } - c.Agents.Router.AllowDirectAgentChat = view.Runtime.Router.AllowDirectAgentChat - if view.Runtime.Router.MaxHops > 0 { - c.Agents.Router.MaxHops = view.Runtime.Router.MaxHops - } - 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...) + c.Agents.Agents = nextAgents nextProviders := map[string]ProviderConfig{} for name, current := range c.Models.Providers { diff --git a/pkg/config/normalized_test.go b/pkg/config/normalized_test.go index 31e26a7..f20e333 100644 --- a/pkg/config/normalized_test.go +++ b/pkg/config/normalized_test.go @@ -4,27 +4,26 @@ import "testing" func TestNormalizedViewProjectsCoreAndRuntime(t *testing.T) { cfg := DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Subagents["coder"] = SubagentConfig{ - Enabled: true, - Role: "coding", - SystemPromptFile: "agents/coder/AGENT.md", - Tools: SubagentToolsConfig{Allowlist: []string{"shell"}}, - Runtime: SubagentRuntimeConfig{Provider: "openai"}, + cfg.Agents.Agents["coder"] = AgentConfig{ + Enabled: true, + Role: "coding", + PromptFile: "agents/coder/AGENT.md", + Tools: AgentToolsConfig{Allowlist: []string{"shell"}}, + Runtime: AgentRuntimeConfig{Provider: "openai"}, } view := cfg.NormalizedView() if view.Core.DefaultProvider != "openai" || view.Core.DefaultModel != "gpt-5.4" { t.Fatalf("unexpected default model projection: %+v", view.Core) } - subcfg, ok := view.Core.Subagents["coder"] + subcfg, ok := view.Core.Agents["coder"] if !ok { - t.Fatalf("expected normalized subagent") + t.Fatalf("expected normalized agent") } if subcfg.Prompt != "agents/coder/AGENT.md" || subcfg.Provider != "openai" { - t.Fatalf("unexpected normalized subagent: %+v", subcfg) + t.Fatalf("unexpected normalized agent: %+v", subcfg) } - if !view.Runtime.Router.Enabled || view.Runtime.Router.Strategy != "rules_first" { - t.Fatalf("unexpected runtime router: %+v", view.Runtime.Router) + if len(view.Runtime.Providers) == 0 { + t.Fatalf("expected normalized providers: %+v", view.Runtime) } } diff --git a/pkg/config/validate.go b/pkg/config/validate.go index 58d1073..f7833c7 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -111,9 +111,7 @@ func Validate(cfg *Config) []error { errs = append(errs, fmt.Errorf("context_compaction.mode=responses_compact requires active provider %q with supports_responses_compact=true", active)) } } - errs = append(errs, validateAgentRouter(cfg)...) - errs = append(errs, validateAgentCommunication(cfg)...) - errs = append(errs, validateSubagents(cfg)...) + errs = append(errs, validateAgents(cfg)...) if cfg.Gateway.Port <= 0 || cfg.Gateway.Port > 65535 { errs = append(errs, fmt.Errorf("gateway.port must be in 1..65535")) @@ -333,93 +331,22 @@ func validateMCPTools(cfg *Config) []error { return errs } -func validateAgentRouter(cfg *Config) []error { - router := cfg.Agents.Router +func validateAgents(cfg *Config) []error { var errs []error - if router.Policy.IntentMaxInputChars < 200 { - errs = append(errs, fmt.Errorf("agents.router.policy.intent_max_input_chars must be >= 200")) - } - if router.Policy.MaxRoundsWithoutUser <= 0 { - errs = append(errs, fmt.Errorf("agents.router.policy.max_rounds_without_user must be > 0")) - } - if strings.TrimSpace(router.Strategy) != "" { - switch strings.TrimSpace(router.Strategy) { - case "rules_first", "round_robin", "manual": - default: - errs = append(errs, fmt.Errorf("agents.router.strategy must be one of: rules_first, round_robin, manual")) - } - } - if router.MaxHops < 0 { - errs = append(errs, fmt.Errorf("agents.router.max_hops must be >= 0")) - } - if router.DefaultTimeoutSec < 0 { - errs = append(errs, fmt.Errorf("agents.router.default_timeout_sec must be >= 0")) - } - if router.Enabled && strings.TrimSpace(router.MainAgentID) == "" { - errs = append(errs, fmt.Errorf("agents.router.main_agent_id is required when agents.router.enabled=true")) - } - for i, rule := range router.Rules { - agentID := strings.TrimSpace(rule.AgentID) - if agentID == "" { - errs = append(errs, fmt.Errorf("agents.router.rules[%d].agent_id is required", i)) - continue - } - if _, ok := cfg.Agents.Subagents[agentID]; !ok { - errs = append(errs, fmt.Errorf("agents.router.rules[%d].agent_id %q not found in agents.subagents", i, agentID)) - } - if len(rule.Keywords) == 0 { - errs = append(errs, fmt.Errorf("agents.router.rules[%d].keywords must not be empty", i)) - } - for _, kw := range rule.Keywords { - if strings.TrimSpace(kw) == "" { - errs = append(errs, fmt.Errorf("agents.router.rules[%d].keywords must not contain empty values", i)) - } - } - } - return errs -} - -func validateAgentCommunication(cfg *Config) []error { - comm := cfg.Agents.Communication - var errs []error - if strings.TrimSpace(comm.Mode) != "" { - switch strings.TrimSpace(comm.Mode) { - case "mediated", "direct": - default: - errs = append(errs, fmt.Errorf("agents.communication.mode must be one of: mediated, direct")) - } - } - if comm.MaxMessagesPerThread < 0 { - errs = append(errs, fmt.Errorf("agents.communication.max_messages_per_thread must be >= 0")) - } - if comm.DefaultMessageTTLSec < 0 { - errs = append(errs, fmt.Errorf("agents.communication.default_message_ttl_sec must be >= 0")) - } - return errs -} - -func validateSubagents(cfg *Config) []error { - var errs []error - if len(cfg.Agents.Subagents) == 0 { + if len(cfg.Agents.Agents) == 0 { return errs } - mainID := strings.TrimSpace(cfg.Agents.Router.MainAgentID) - if cfg.Agents.Router.Enabled && mainID != "" { - if _, ok := cfg.Agents.Subagents[mainID]; !ok { - errs = append(errs, fmt.Errorf("agents.router.main_agent_id %q not found in agents.subagents", mainID)) - } - } - for agentID, raw := range cfg.Agents.Subagents { + for agentID, raw := range cfg.Agents.Agents { id := strings.TrimSpace(agentID) if id == "" { - errs = append(errs, fmt.Errorf("agents.subagents contains an empty agent id")) + errs = append(errs, fmt.Errorf("agents.agents contains an empty agent id")) continue } if strings.TrimSpace(raw.Type) != "" { switch strings.TrimSpace(raw.Type) { - case "router", "worker", "reviewer", "observer": + case "agent", "npc", "tool": default: - errs = append(errs, fmt.Errorf("agents.subagents.%s.type must be one of: router, worker, reviewer, observer", id)) + errs = append(errs, fmt.Errorf("agents.agents.%s.type must be one of: agent, npc, tool", id)) } } transport := strings.TrimSpace(raw.Transport) @@ -427,111 +354,51 @@ func validateSubagents(cfg *Config) []error { switch transport { case "local", "node": default: - errs = append(errs, fmt.Errorf("agents.subagents.%s.transport must be one of: local, node", id)) - } - } - if policy := strings.TrimSpace(raw.NotifyMainPolicy); policy != "" { - switch policy { - case "final_only", "milestone", "on_blocked", "always", "internal_only": - default: - errs = append(errs, fmt.Errorf("agents.subagents.%s.notify_main_policy must be one of: final_only, milestone, on_blocked, always, internal_only", id)) + errs = append(errs, fmt.Errorf("agents.agents.%s.transport must be one of: local, node", id)) } } if transport == "node" && strings.TrimSpace(raw.NodeID) == "" { - errs = append(errs, fmt.Errorf("agents.subagents.%s.node_id is required when transport=node", id)) + errs = append(errs, fmt.Errorf("agents.agents.%s.node_id is required when transport=node", id)) } if raw.Runtime.TimeoutSec < 0 { - errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.timeout_sec must be >= 0", id)) + errs = append(errs, fmt.Errorf("agents.agents.%s.runtime.timeout_sec must be >= 0", id)) } if raw.Runtime.MaxRetries < 0 { - errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.max_retries must be >= 0", id)) + errs = append(errs, fmt.Errorf("agents.agents.%s.runtime.max_retries must be >= 0", id)) } if raw.Runtime.RetryBackoffMs < 0 { - errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.retry_backoff_ms must be >= 0", id)) + errs = append(errs, fmt.Errorf("agents.agents.%s.runtime.retry_backoff_ms must be >= 0", id)) } if raw.Runtime.MaxTaskChars < 0 { - errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.max_task_chars must be >= 0", id)) + errs = append(errs, fmt.Errorf("agents.agents.%s.runtime.max_task_chars must be >= 0", id)) } if raw.Runtime.MaxResultChars < 0 { - errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.max_result_chars must be >= 0", id)) + errs = append(errs, fmt.Errorf("agents.agents.%s.runtime.max_result_chars must be >= 0", id)) } if raw.Runtime.MaxParallelRuns < 0 { - errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.max_parallel_runs must be >= 0", id)) + errs = append(errs, fmt.Errorf("agents.agents.%s.runtime.max_parallel_runs must be >= 0", id)) } if raw.Tools.MaxParallelCalls < 0 { - errs = append(errs, fmt.Errorf("agents.subagents.%s.tools.max_parallel_calls must be >= 0", id)) + errs = append(errs, fmt.Errorf("agents.agents.%s.tools.max_parallel_calls must be >= 0", id)) } - if raw.Enabled && transport != "node" && strings.TrimSpace(raw.SystemPromptFile) == "" { - errs = append(errs, fmt.Errorf("agents.subagents.%s.system_prompt_file is required when enabled=true", id)) + if raw.Enabled && transport != "node" && strings.TrimSpace(raw.PromptFile) == "" { + errs = append(errs, fmt.Errorf("agents.agents.%s.prompt_file is required when enabled=true", id)) } - if promptFile := strings.TrimSpace(raw.SystemPromptFile); promptFile != "" { + if promptFile := strings.TrimSpace(raw.PromptFile); promptFile != "" { if filepath.IsAbs(promptFile) { - errs = append(errs, fmt.Errorf("agents.subagents.%s.system_prompt_file must be relative", id)) + errs = append(errs, fmt.Errorf("agents.agents.%s.prompt_file must be relative", id)) } if cleaned := filepath.Clean(promptFile); strings.HasPrefix(cleaned, "..") { - errs = append(errs, fmt.Errorf("agents.subagents.%s.system_prompt_file must stay within workspace", id)) + errs = append(errs, fmt.Errorf("agents.agents.%s.prompt_file must stay within workspace", id)) } } if provider := strings.TrimSpace(raw.Runtime.Provider); provider != "" && !ProviderExists(cfg, provider) { - errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.provider %q not found in providers", id, provider)) - } - for _, sender := range raw.AcceptFrom { - sender = strings.TrimSpace(sender) - if sender == "" { - errs = append(errs, fmt.Errorf("agents.subagents.%s.accept_from must not contain empty values", id)) - continue - } - if sender != "user" && sender != id { - if _, ok := cfg.Agents.Subagents[sender]; !ok { - errs = append(errs, fmt.Errorf("agents.subagents.%s.accept_from references unknown agent %q", id, sender)) - } - } - } - for _, target := range raw.CanTalkTo { - target = strings.TrimSpace(target) - if target == "" { - errs = append(errs, fmt.Errorf("agents.subagents.%s.can_talk_to must not contain empty values", id)) - continue - } - if target != "user" { - if _, ok := cfg.Agents.Subagents[target]; !ok { - errs = append(errs, fmt.Errorf("agents.subagents.%s.can_talk_to references unknown agent %q", id, target)) - } - } - } - if raw.RequiresMainMediation && mainID != "" && id == mainID { - errs = append(errs, fmt.Errorf("agents.subagents.%s.requires_main_mediation must be false for main agent", id)) - } - } - for agentID, raw := range cfg.Agents.Subagents { - id := strings.TrimSpace(agentID) - for _, target := range raw.CanTalkTo { - target = strings.TrimSpace(target) - if target == "" || target == "user" { - continue - } - peer, ok := cfg.Agents.Subagents[target] - if !ok { - continue - } - if !containsString(raw.AcceptFrom, target) && !containsString(peer.AcceptFrom, id) { - errs = append(errs, fmt.Errorf("agents.subagents.%s.can_talk_to %q is not reciprocated by accept_from", id, target)) - } + errs = append(errs, fmt.Errorf("agents.agents.%s.runtime.provider %q not found in providers", id, provider)) } } return errs } -func containsString(items []string, target string) bool { - target = strings.TrimSpace(target) - for _, item := range items { - if strings.TrimSpace(item) == target { - return true - } - } - return false -} - func validateProviderConfig(path string, p ProviderConfig) []error { var errs []error authMode := strings.ToLower(strings.TrimSpace(p.Auth)) diff --git a/pkg/config/validate_test.go b/pkg/config/validate_test.go index a7ef6e8..e7be7c1 100644 --- a/pkg/config/validate_test.go +++ b/pkg/config/validate_test.go @@ -14,26 +14,20 @@ func TestDefaultConfigGeneratesGatewayToken(t *testing.T) { } } -func TestValidateSubagentsAllowsKnownPeers(t *testing.T) { +func TestValidateAgentsAllowsKnownPeers(t *testing.T) { t.Parallel() cfg := DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Router.MainAgentID = "main" - cfg.Agents.Subagents["main"] = SubagentConfig{ - Enabled: true, - Type: "router", - SystemPromptFile: "agents/main/AGENT.md", - AcceptFrom: []string{"user", "coder"}, - CanTalkTo: []string{"coder"}, + cfg.Agents.Agents["main"] = AgentConfig{ + Enabled: true, + Type: "agent", + PromptFile: "agents/main/AGENT.md", } - cfg.Agents.Subagents["coder"] = SubagentConfig{ - Enabled: true, - Type: "worker", - SystemPromptFile: "agents/coder/AGENT.md", - AcceptFrom: []string{"main"}, - CanTalkTo: []string{"main"}, - Runtime: SubagentRuntimeConfig{ + cfg.Agents.Agents["coder"] = AgentConfig{ + Enabled: true, + Type: "agent", + PromptFile: "agents/coder/AGENT.md", + Runtime: AgentRuntimeConfig{ Provider: "openai", }, } @@ -43,29 +37,14 @@ func TestValidateSubagentsAllowsKnownPeers(t *testing.T) { } } -func TestValidateSubagentsRejectsUnknownPeer(t *testing.T) { +func TestValidateAgentsRejectsAbsolutePromptFile(t *testing.T) { t.Parallel() cfg := DefaultConfig() - cfg.Agents.Subagents["coder"] = SubagentConfig{ - Enabled: true, - SystemPromptFile: "agents/coder/AGENT.md", - AcceptFrom: []string{"main"}, - } - - if errs := Validate(cfg); len(errs) == 0 { - t.Fatalf("expected validation errors") - } -} - -func TestValidateSubagentsRejectsAbsolutePromptFile(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.Agents.Subagents["coder"] = SubagentConfig{ - Enabled: true, - SystemPromptFile: "/tmp/AGENT.md", - Runtime: SubagentRuntimeConfig{ + cfg.Agents.Agents["coder"] = AgentConfig{ + Enabled: true, + PromptFile: "/tmp/AGENT.md", + Runtime: AgentRuntimeConfig{ Provider: "openai", }, } @@ -75,13 +54,13 @@ func TestValidateSubagentsRejectsAbsolutePromptFile(t *testing.T) { } } -func TestValidateSubagentsRequiresPromptFileWhenEnabled(t *testing.T) { +func TestValidateAgentsRequiresPromptFileWhenEnabled(t *testing.T) { t.Parallel() cfg := DefaultConfig() - cfg.Agents.Subagents["coder"] = SubagentConfig{ + cfg.Agents.Agents["coder"] = AgentConfig{ Enabled: true, - Runtime: SubagentRuntimeConfig{ + Runtime: AgentRuntimeConfig{ Provider: "openai", }, } @@ -91,20 +70,18 @@ func TestValidateSubagentsRequiresPromptFileWhenEnabled(t *testing.T) { } } -func TestValidateNodeBackedSubagentAllowsMissingPromptFile(t *testing.T) { +func TestValidateNodeBackedAgentAllowsMissingPromptFile(t *testing.T) { t.Parallel() cfg := DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Router.MainAgentID = "main" - cfg.Agents.Subagents["main"] = SubagentConfig{ - Enabled: true, - Type: "router", - SystemPromptFile: "agents/main/AGENT.md", + cfg.Agents.Agents["main"] = AgentConfig{ + Enabled: true, + Type: "agent", + PromptFile: "agents/main/AGENT.md", } - cfg.Agents.Subagents["node.edge.main"] = SubagentConfig{ + cfg.Agents.Agents["node.edge.main"] = AgentConfig{ Enabled: true, - Type: "worker", + Type: "agent", Transport: "node", NodeID: "edge", } @@ -114,24 +91,6 @@ func TestValidateNodeBackedSubagentAllowsMissingPromptFile(t *testing.T) { } } -func TestValidateSubagentsRejectsInvalidNotifyMainPolicy(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.Agents.Subagents["coder"] = SubagentConfig{ - Enabled: true, - SystemPromptFile: "agents/coder/AGENT.md", - NotifyMainPolicy: "loud", - Runtime: SubagentRuntimeConfig{ - Provider: "openai", - }, - } - - if errs := Validate(cfg); len(errs) == 0 { - t.Fatalf("expected validation errors") - } -} - func TestDefaultConfigDisablesGatewayNodeP2P(t *testing.T) { t.Parallel() diff --git a/pkg/rpc/subagent.go b/pkg/rpc/subagent.go deleted file mode 100644 index d6234f8..0000000 --- a/pkg/rpc/subagent.go +++ /dev/null @@ -1,87 +0,0 @@ -package rpc - -import ( - "context" - - "github.com/YspCoder/clawgo/pkg/tools" -) - -type SubagentService interface { - List(context.Context, ListSubagentsRequest) (*ListSubagentsResponse, *Error) - Snapshot(context.Context, SnapshotRequest) (*SnapshotResponse, *Error) - Get(context.Context, GetSubagentRequest) (*GetSubagentResponse, *Error) - Spawn(context.Context, SpawnSubagentRequest) (*SpawnSubagentResponse, *Error) - DispatchAndWait(context.Context, DispatchAndWaitRequest) (*DispatchAndWaitResponse, *Error) - Registry(context.Context, RegistryRequest) (*RegistryResponse, *Error) -} - -type ListSubagentsRequest struct{} - -type ListSubagentsResponse struct { - Items []*tools.SubagentTask `json:"items"` -} - -type SnapshotRequest struct { - Limit int `json:"limit,omitempty"` -} - -type SnapshotResponse struct { - Snapshot tools.RuntimeSnapshot `json:"snapshot"` -} - -type GetSubagentRequest struct { - ID string `json:"id"` -} - -type GetSubagentResponse struct { - Found bool `json:"found"` - Task *tools.SubagentTask `json:"task,omitempty"` -} - -type SpawnSubagentRequest struct { - Task string `json:"task"` - Label string `json:"label,omitempty"` - Role string `json:"role,omitempty"` - AgentID string `json:"agent_id,omitempty"` - MaxRetries int `json:"max_retries,omitempty"` - RetryBackoffMS int `json:"retry_backoff_ms,omitempty"` - TimeoutSec int `json:"timeout_sec,omitempty"` - MaxTaskChars int `json:"max_task_chars,omitempty"` - MaxResultChars int `json:"max_result_chars,omitempty"` - Channel string `json:"channel,omitempty"` - ChatID string `json:"chat_id,omitempty"` -} - -type SpawnSubagentResponse struct { - Message string `json:"message"` -} - -type DispatchAndWaitRequest struct { - Task string `json:"task"` - Label string `json:"label,omitempty"` - Role string `json:"role,omitempty"` - AgentID string `json:"agent_id,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` - ParentRunID string `json:"parent_run_id,omitempty"` - Channel string `json:"channel,omitempty"` - ChatID string `json:"chat_id,omitempty"` - MaxRetries int `json:"max_retries,omitempty"` - RetryBackoffMS int `json:"retry_backoff_ms,omitempty"` - TimeoutSec int `json:"timeout_sec,omitempty"` - MaxTaskChars int `json:"max_task_chars,omitempty"` - MaxResultChars int `json:"max_result_chars,omitempty"` - WaitTimeoutSec int `json:"wait_timeout_sec,omitempty"` -} - -type DispatchAndWaitResponse struct { - Task *tools.SubagentTask `json:"task,omitempty"` - Reply *tools.RouterReply `json:"reply,omitempty"` - Merged string `json:"merged,omitempty"` -} - -type RegistryRequest struct{} - -type RegistryResponse struct { - Items []map[string]interface{} `json:"items"` -} diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 56a9cec..fd828dc 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -483,8 +483,8 @@ func detectSessionKind(key string) string { switch { case strings.HasPrefix(k, "cron:"): return "cron" - case strings.HasPrefix(k, "subagent:") || strings.Contains(k, ":subagent:"): - return "subagent" + case strings.HasPrefix(k, "agent:") || strings.Contains(k, ":agent:"): + return "agent" case strings.HasPrefix(k, "hook:"): return "hook" case strings.HasPrefix(k, "node:"): @@ -615,7 +615,7 @@ func mapKindToChatType(kind string) string { switch strings.ToLower(strings.TrimSpace(kind)) { case "main": return "direct" - case "cron", "subagent", "hook", "node": + case "cron", "agent", "hook", "node": return "internal" default: return "unknown" diff --git a/pkg/tools/agent.go b/pkg/tools/agent.go new file mode 100644 index 0000000..819578e --- /dev/null +++ b/pkg/tools/agent.go @@ -0,0 +1,1223 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/YspCoder/clawgo/pkg/bus" + "github.com/YspCoder/clawgo/pkg/ekg" + "github.com/YspCoder/clawgo/pkg/providers" +) + +type AgentTask struct { + ID string `json:"id"` + Task string `json:"task"` + RunKind string `json:"run_kind,omitempty"` + AgentID string `json:"agent_id"` + Target *TargetRef `json:"target,omitempty"` + ExecutionPolicy *ExecutionPolicy `json:"execution_policy,omitempty"` + RetryCount int `json:"retry_count,omitempty"` + WorldDecision *WorldDecisionContext `json:"world_decision,omitempty"` + Origin *OriginRef `json:"origin,omitempty"` + Status string `json:"status"` + Result string `json:"result,omitempty"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` +} + +type WorldDecisionContext struct { + WorldTick int64 `json:"world_tick,omitempty"` + WorldSnapshot map[string]interface{} `json:"world_snapshot,omitempty"` + NPCSnapshot map[string]interface{} `json:"npc_snapshot,omitempty"` + VisibleEvents []map[string]interface{} `json:"visible_events,omitempty"` + IntentSchemaVersion string `json:"intent_schema_version,omitempty"` +} + +type OriginRef struct { + Channel string `json:"channel,omitempty"` + ChatID string `json:"chat_id,omitempty"` +} + +type TargetRef struct { + Transport string `json:"transport,omitempty"` + NodeID string `json:"node_id,omitempty"` + ParentAgentID string `json:"parent_agent_id,omitempty"` +} + +type ExecutionPolicy struct { + PromptFile string `json:"prompt_file,omitempty"` + ToolAllowlist []string `json:"tool_allowlist,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` + RetryBackoff int `json:"retry_backoff,omitempty"` + TimeoutSec int `json:"timeout_sec,omitempty"` + MaxTaskChars int `json:"max_task_chars,omitempty"` + MaxResultChars int `json:"max_result_chars,omitempty"` +} + +type AgentManager struct { + tasks map[string]*AgentTask + cancelFuncs map[string]context.CancelFunc + waiters map[string]map[chan struct{}]struct{} + recoverableTaskIDs []string + archiveAfterMinute int64 + mu sync.RWMutex + provider providers.LLMProvider + bus *bus.MessageBus + workspace string + nextID int + runFunc AgentRunFunc + profileStore *AgentProfileStore + runStore *AgentRunStore + mailboxStore *AgentMailboxStore + ekg *ekg.Engine +} + +type AgentSpawnOptions struct { + Task string + RunKind string + Role string + AgentID string + Target *TargetRef + ExecutionPolicy *ExecutionPolicy + Origin *OriginRef + WorldDecision *WorldDecisionContext +} + +func NewAgentManager(provider providers.LLMProvider, workspace string, bus *bus.MessageBus) *AgentManager { + store := NewAgentProfileStore(workspace) + runStore := NewAgentRunStore(workspace) + mailboxStore := NewAgentMailboxStore(workspace) + mgr := &AgentManager{ + tasks: make(map[string]*AgentTask), + cancelFuncs: make(map[string]context.CancelFunc), + waiters: make(map[string]map[chan struct{}]struct{}), + archiveAfterMinute: 60, + provider: provider, + bus: bus, + workspace: workspace, + nextID: 1, + 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) + } + } + mgr.nextID = runStore.NextIDSeed() + } + go mgr.resumeRecoveredTasks() + return mgr +} + +func (sm *AgentManager) Spawn(ctx context.Context, opts AgentSpawnOptions) (string, error) { + task, err := sm.spawnTask(ctx, opts) + if err != nil { + return "", err + } + desc := fmt.Sprintf("Spawned agent for task: %s (agent=%s)", task.Task, task.AgentID) + return desc, nil +} + +func (sm *AgentManager) SpawnTask(ctx context.Context, opts AgentSpawnOptions) (*AgentTask, error) { + return sm.spawnTask(ctx, opts) +} + +func (sm *AgentManager) spawnTask(ctx context.Context, opts AgentSpawnOptions) (*AgentTask, error) { + task := strings.TrimSpace(opts.Task) + if task == "" { + return nil, fmt.Errorf("task is required") + } + role := strings.TrimSpace(opts.Role) + agentID := normalizeAgentIdentifier(opts.AgentID) + var profile *AgentProfile + if sm.profileStore != nil { + if agentID != "" { + if p, ok, err := sm.profileStore.Get(agentID); err != nil { + return nil, err + } else if ok { + profile = p + } + } else if role != "" { + if p, ok, err := sm.profileStore.FindByRole(role); err != nil { + return nil, err + } else if ok { + profile = p + agentID = normalizeAgentIdentifier(p.AgentID) + } + } + } + if agentID == "" { + agentID = normalizeAgentIdentifier(role) + } + if agentID == "" { + agentID = "default" + } + systemPromptFile := "" + transport := "local" + nodeID := "" + parentAgentID := "" + policy := &ExecutionPolicy{RetryBackoff: 1000} + if profile == nil && sm.profileStore != nil { + if p, ok, err := sm.profileStore.Get(agentID); err != nil { + return nil, err + } else if ok { + profile = p + } + } + if profile != nil { + if strings.EqualFold(strings.TrimSpace(profile.Status), "disabled") { + return nil, fmt.Errorf("agent profile '%s' is disabled", profile.AgentID) + } + transport = strings.TrimSpace(profile.Transport) + if transport == "" { + transport = "local" + } + nodeID = strings.TrimSpace(profile.NodeID) + parentAgentID = strings.TrimSpace(profile.ParentAgentID) + systemPromptFile = strings.TrimSpace(profile.PromptFile) + policy = &ExecutionPolicy{ + PromptFile: strings.TrimSpace(profile.PromptFile), + ToolAllowlist: append([]string(nil), profile.ToolAllowlist...), + MaxRetries: profile.MaxRetries, + RetryBackoff: profile.RetryBackoff, + TimeoutSec: profile.TimeoutSec, + MaxTaskChars: profile.MaxTaskChars, + MaxResultChars: profile.MaxResultChars, + } + } + policy = mergeExecutionPolicy(policy, opts.ExecutionPolicy) + systemPromptFile = strings.TrimSpace(policy.PromptFile) + toolAllowlist := append([]string(nil), policy.ToolAllowlist...) + maxRetries := policy.MaxRetries + retryBackoff := policy.RetryBackoff + timeoutSec := policy.TimeoutSec + maxTaskChars := policy.MaxTaskChars + maxResultChars := policy.MaxResultChars + if maxTaskChars > 0 && len(task) > maxTaskChars { + return nil, fmt.Errorf("task exceeds max_task_chars quota (%d > %d)", len(task), maxTaskChars) + } + maxRetries = normalizePositiveBound(maxRetries, 0, 8) + retryBackoff = normalizePositiveBound(retryBackoff, 500, 120000) + timeoutSec = normalizePositiveBound(timeoutSec, 0, 3600) + maxTaskChars = normalizePositiveBound(maxTaskChars, 0, 400000) + maxResultChars = normalizePositiveBound(maxResultChars, 0, 400000) + sm.mu.Lock() + defer sm.mu.Unlock() + + taskID := fmt.Sprintf("agent-%d", sm.nextID) + sm.nextID++ + now := time.Now().UnixMilli() + agentTask := &AgentTask{ + ID: taskID, + Task: task, + RunKind: normalizeAgentRunKind(opts.RunKind, opts.WorldDecision), + AgentID: agentID, + Target: mergeTargetRef(&TargetRef{Transport: transport, NodeID: nodeID, ParentAgentID: parentAgentID}, opts.Target), + ExecutionPolicy: &ExecutionPolicy{PromptFile: systemPromptFile, ToolAllowlist: toolAllowlist, MaxRetries: maxRetries, RetryBackoff: retryBackoff, TimeoutSec: timeoutSec, MaxTaskChars: maxTaskChars, MaxResultChars: maxResultChars}, + RetryCount: 0, + WorldDecision: cloneWorldDecisionContext(opts.WorldDecision), + Origin: cloneOriginRef(opts.Origin), + Status: RuntimeStatusRouting, + Created: now, + Updated: now, + } + taskCtx, cancel := context.WithCancel(ctx) + sm.tasks[taskID] = agentTask + sm.cancelFuncs[taskID] = cancel + sm.recordMailboxMessageLocked(agentTask, AgentMessage{ + ThreadID: taskMailboxThreadID(agentTask), + FromAgent: "main", + ToAgent: agentID, + Type: "task", + Content: task, + Status: "queued", + CreatedAt: now, + }) + sm.persistTaskLocked(agentTask, "spawned", "") + + go sm.runTask(taskCtx, agentTask) + return cloneAgentTask(agentTask), nil +} + +func (sm *AgentManager) runTask(ctx context.Context, task *AgentTask) { + defer func() { + sm.mu.Lock() + delete(sm.cancelFuncs, task.ID) + sm.mu.Unlock() + }() + + sm.mu.Lock() + task.Status = RuntimeStatusRunning + task.Created = time.Now().UnixMilli() + task.Updated = task.Created + sm.persistTaskLocked(task, "started", "") + sm.mu.Unlock() + + result, runErr := sm.runWithRetry(ctx, task) + sm.mu.Lock() + if runErr != nil { + task.Status = RuntimeStatusFailed + task.Result = fmt.Sprintf("Error: %v", runErr) + task.Result = applyAgentResultQuota(task.Result, maxResultCharsFromPolicy(task.ExecutionPolicy)) + task.Updated = time.Now().UnixMilli() + sm.recordMailboxMessageLocked(task, AgentMessage{ + ThreadID: taskMailboxThreadID(task), + FromAgent: task.AgentID, + ToAgent: "main", + Type: "result", + Content: task.Result, + Status: "delivered", + CreatedAt: task.Updated, + }) + sm.persistTaskLocked(task, "failed", task.Result) + } else { + task.Status = RuntimeStatusCompleted + task.Result = applyAgentResultQuota(result, maxResultCharsFromPolicy(task.ExecutionPolicy)) + task.Updated = time.Now().UnixMilli() + sm.recordMailboxMessageLocked(task, AgentMessage{ + ThreadID: taskMailboxThreadID(task), + FromAgent: task.AgentID, + ToAgent: "main", + Type: "result", + Content: task.Result, + Status: "delivered", + CreatedAt: task.Updated, + }) + sm.persistTaskLocked(task, "completed", task.Result) + } + sm.mu.Unlock() + + sm.recordEKG(task, runErr) + + // 2. Result broadcast + if sm.bus != nil && shouldNotifyMainOnFinal(task, runErr) { + announceContent, notifyReason := buildAgentMainNotification(task, runErr) + sessionKey := BuildAgentSessionKey(task.AgentID, task.ID) + originChannel, originChatID := OriginValues(task.Origin) + memoryNS := sm.ResolveMemoryNamespace(task.AgentID) + sm.bus.PublishInbound(bus.InboundMessage{ + Channel: "system", + SenderID: fmt.Sprintf("agent:%s", task.ID), + ChatID: fmt.Sprintf("%s:%s", originChannel, originChatID), + SessionKey: sessionKey, + Content: announceContent, + Metadata: map[string]string{ + "trigger": "agent", + "agent_run_id": task.ID, + "agent_id": task.AgentID, + "session_key": sessionKey, + "memory_ns": memoryNS, + "retry_count": fmt.Sprintf("%d", task.RetryCount), + "timeout_sec": fmt.Sprintf("%d", timeoutSecFromPolicy(task.ExecutionPolicy)), + "status": task.Status, + "notify_reason": notifyReason, + }, + }) + } + sm.mu.Lock() + sm.notifyTaskWaitersLocked(task.ID) + sm.mu.Unlock() +} + +func (sm *AgentManager) recordEKG(task *AgentTask, runErr error) { + if sm == nil || sm.ekg == nil || task == nil { + return + } + status := "success" + logText := strings.TrimSpace(task.Result) + if runErr != nil { + status = "error" + if isBlockedAgentError(runErr) { + logText = "blocked: " + strings.TrimSpace(task.Result) + } + } + sm.ekg.Record(ekg.Event{ + TaskID: task.ID, + Session: BuildAgentSessionKey(task.AgentID, task.ID), + Channel: originChannelOrDefault(task.Origin), + Source: "agent", + Status: status, + Log: logText, + }) +} + +func shouldNotifyMainOnFinal(task *AgentTask, runErr error) bool { + _ = runErr + return !IsWorldDecisionTask(task) +} + +func buildAgentMainNotification(task *AgentTask, runErr error) (string, string) { + status := "completed" + reason := "final" + if runErr != nil { + status = "failed" + if isBlockedAgentError(runErr) { + status = "blocked" + reason = "blocked" + } + } + return fmt.Sprintf( + "Agent update\nagent: %s\nrun: %s\nstatus: %s\nreason: %s\ntask: %s\nsummary: %s", + strings.TrimSpace(task.AgentID), + strings.TrimSpace(task.ID), + status, + reason, + summarizeAgentText(task.Task, 120), + summarizeAgentText(task.Result, 280), + ), reason +} + +func isBlockedAgentError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + msg := strings.ToLower(strings.TrimSpace(err.Error())) + if msg == "" { + return false + } + blockedHints := []string{ + "timeout", + "deadline exceeded", + "quota", + "rate limit", + "too many requests", + "permission denied", + "requires input", + "waiting for reply", + "blocked", + } + for _, hint := range blockedHints { + if strings.Contains(msg, hint) { + return true + } + } + return false +} + +func summarizeAgentText(s string, max int) string { + s = strings.TrimSpace(strings.ReplaceAll(s, "\r\n", "\n")) + s = strings.ReplaceAll(s, "\n", " ") + s = strings.Join(strings.Fields(s), " ") + if s == "" { + return "(empty)" + } + if max > 0 && len(s) > max { + return strings.TrimSpace(s[:max-3]) + "..." + } + return s +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func (sm *AgentManager) runWithRetry(ctx context.Context, task *AgentTask) (string, error) { + maxRetries := maxRetriesFromPolicy(task.ExecutionPolicy) + backoffMs := retryBackoffFromPolicy(task.ExecutionPolicy) + timeoutSec := timeoutSecFromPolicy(task.ExecutionPolicy) + + 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) + }, + ) + if err == nil { + sm.mu.Lock() + task.RetryCount = attempt + task.Updated = time.Now().UnixMilli() + sm.persistTaskLocked(task, "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()) + sm.mu.Unlock() + if attempt >= maxRetries { + break + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(time.Duration(backoffMs) * time.Millisecond): + } + } + if lastErr == nil { + lastErr = fmt.Errorf("agent task failed with unknown error") + } + return "", lastErr +} + +func (sm *AgentManager) taskWatchdogProgress(task *AgentTask) int { + if sm == nil || task == nil { + return 0 + } + 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 *AgentManager) taskCanAutoExtend(task *AgentTask) 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 *AgentManager) executeTaskOnce(ctx context.Context, task *AgentTask) (string, error) { + if task == nil { + return "", fmt.Errorf("agent task is nil") + } + pending, consumedIDs := sm.consumeThreadInbox(task) + if sm.runFunc != nil { + result, err := sm.runFunc(ctx, task) + if err != nil { + sm.restoreMessageStatuses(consumedIDs) + } else { + sm.ackMessageStatuses(consumedIDs) + } + return result, err + } + if sm.provider == nil { + sm.restoreMessageStatuses(consumedIDs) + return "", fmt.Errorf("no llm provider configured for agent execution") + } + + systemPrompt := sm.resolveSystemPrompt(task) + messages := []providers.Message{ + { + Role: "system", + Content: systemPrompt, + }, + { + Role: "user", + Content: task.Task, + }, + } + if strings.TrimSpace(pending) != "" { + messages = append(messages, providers.Message{ + Role: "user", + Content: "Mailbox updates on this thread:\n" + pending, + }) + } + + response, err := sm.provider.Chat(ctx, messages, nil, sm.provider.GetDefaultModel(), map[string]interface{}{ + "max_tokens": 4096, + }) + if err != nil { + sm.restoreMessageStatuses(consumedIDs) + return "", err + } + sm.ackMessageStatuses(consumedIDs) + return response.Content, nil +} + +func (sm *AgentManager) resolveSystemPrompt(task *AgentTask) string { + if IsWorldDecisionTask(task) { + return "You are an NPC in a simulated world. Return only a JSON action intent. You may propose actions but must not directly alter world state." + } + systemPrompt := "You are a agent. 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 { + return systemPrompt + } + if promptFile := PromptFileFromPolicy(task.ExecutionPolicy); promptFile != "" { + if promptText := sm.readWorkspacePromptFile(promptFile); promptText != "" { + return systemPrompt + "\n\nAgent policy (" + promptFile + "):\n" + promptText + } + } + return systemPrompt +} + +func (sm *AgentManager) readWorkspacePromptFile(relPath string) string { + ws := strings.TrimSpace(sm.workspace) + relPath = strings.TrimSpace(relPath) + if ws == "" || relPath == "" || filepath.IsAbs(relPath) { + return "" + } + fullPath := filepath.Clean(filepath.Join(ws, relPath)) + relToWorkspace, err := filepath.Rel(ws, fullPath) + if err != nil || strings.HasPrefix(relToWorkspace, "..") { + return "" + } + data, err := os.ReadFile(fullPath) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +type AgentRunFunc func(ctx context.Context, task *AgentTask) (string, error) + +func (sm *AgentManager) SetRunFunc(f AgentRunFunc) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.runFunc = f + go sm.resumeRecoveredTasks() +} + +func (sm *AgentManager) ProfileStore() *AgentProfileStore { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.profileStore +} + +func (sm *AgentManager) ResolveMemoryNamespace(agentID string) string { + if sm == nil { + return normalizeAgentIdentifier(agentID) + } + agentID = normalizeAgentIdentifier(agentID) + if agentID == "" { + return "default" + } + sm.mu.RLock() + store := sm.profileStore + sm.mu.RUnlock() + if store != nil { + if profile, ok, err := store.Get(agentID); err == nil && ok && profile != nil { + if ns := normalizeAgentIdentifier(profile.MemoryNamespace); ns != "" { + return ns + } + } + } + return agentID +} + +func (sm *AgentManager) resumeRecoveredTasks() { + if sm == nil { + return + } + sm.mu.Lock() + if sm.runFunc == nil && sm.provider == nil { + sm.mu.Unlock() + return + } + taskIDs := append([]string(nil), sm.recoverableTaskIDs...) + sm.recoverableTaskIDs = nil + toResume := make([]*AgentTask, 0, len(taskIDs)) + for _, taskID := range taskIDs { + task, ok := sm.tasks[taskID] + if !ok || task == nil || task.Status != "running" { + continue + } + task.Updated = time.Now().UnixMilli() + sm.persistTaskLocked(task, "recovered", "auto-resumed after restart") + toResume = append(toResume, task) + } + sm.mu.Unlock() + + for _, task := range toResume { + taskCtx, cancel := context.WithCancel(context.Background()) + sm.mu.Lock() + sm.cancelFuncs[task.ID] = cancel + sm.mu.Unlock() + go sm.runTask(taskCtx, task) + } +} + +func (sm *AgentManager) NextTaskSequence() int { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.nextID +} + +func (sm *AgentManager) GetTask(taskID string) (*AgentTask, 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 *AgentManager) ListTasks() []*AgentTask { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.pruneArchivedLocked() + + tasks := make([]*AgentTask, 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{}{} + } + if sm.runStore != nil { + for _, task := range sm.runStore.List() { + if _, ok := seen[task.ID]; ok { + continue + } + tasks = append(tasks, task) + } + } + return tasks +} + +func (sm *AgentManager) Events(taskID string, limit int) ([]AgentRunEvent, error) { + if sm.runStore == nil { + return nil, nil + } + return sm.runStore.Events(taskID, limit) +} + +func (sm *AgentManager) RuntimeSnapshot(limit int) RuntimeSnapshot { + if sm == nil { + return RuntimeSnapshot{} + } + tasks := sm.ListTasks() + snapshot := RuntimeSnapshot{ + Tasks: make([]TaskRecord, 0, len(tasks)), + Runs: make([]RunRecord, 0, len(tasks)), + } + 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 _, evt := range evts { + snapshot.Events = append(snapshot.Events, EventRecord{ + ID: EventRecordID(evt.RunID, evt.Type, evt.At), + RunID: evt.RunID, + TaskID: evt.RunID, + AgentID: evt.AgentID, + Type: evt.Type, + Status: evt.Status, + Message: evt.Message, + RetryCount: evt.RetryCount, + At: evt.At, + }) + } + } + } + return snapshot +} + +func (sm *AgentManager) Inbox(agentID string, limit int) ([]AgentMessage, error) { + if sm.mailboxStore == nil { + return nil, nil + } + return sm.mailboxStore.Inbox(agentID, limit) +} + +func (sm *AgentManager) SendAgentMessage(fromAgent, toAgent, messageType, content, threadID string) (AgentMessage, error) { + if sm == nil || sm.mailboxStore == nil { + return AgentMessage{}, fmt.Errorf("mailbox store not available") + } + fromAgent = strings.TrimSpace(fromAgent) + toAgent = strings.TrimSpace(toAgent) + content = strings.TrimSpace(content) + if fromAgent == "" || toAgent == "" { + return AgentMessage{}, fmt.Errorf("from_agent and to_agent are required") + } + if content == "" { + return AgentMessage{}, fmt.Errorf("content is required") + } + now := time.Now().UnixMilli() + threadID = sm.mailboxStore.EnsureThreadID(threadID) + return sm.mailboxStore.AppendMessage(AgentMessage{ + ThreadID: threadID, + FromAgent: fromAgent, + ToAgent: toAgent, + Type: firstNonEmptyMessageType(messageType), + Content: content, + Status: "queued", + CreatedAt: now, + }) +} + +func (sm *AgentManager) pruneArchivedLocked() { + if sm.archiveAfterMinute <= 0 { + return + } + cutoff := time.Now().Add(-time.Duration(sm.archiveAfterMinute) * time.Minute).UnixMilli() + for id, t := range sm.tasks { + if !IsTerminalRuntimeStatus(t.Status) { + continue + } + if t.Updated > 0 && t.Updated < cutoff { + delete(sm.tasks, id) + delete(sm.cancelFuncs, id) + } + } +} + +func normalizePositiveBound(v, min, max int) int { + if v < min { + return min + } + if max > 0 && v > max { + return max + } + return v +} + +func applyAgentResultQuota(result string, maxChars int) string { + if maxChars <= 0 { + return result + } + if len(result) <= maxChars { + return result + } + suffix := "\n\n[TRUNCATED: result exceeds max_result_chars quota]" + trimmed := result[:maxChars] + if len(trimmed)+len(suffix) > maxChars && maxChars > len(suffix) { + trimmed = trimmed[:maxChars-len(suffix)] + } + return strings.TrimSpace(trimmed) + suffix +} + +func normalizeAgentIdentifier(in string) string { + in = strings.TrimSpace(strings.ToLower(in)) + if in == "" { + return "" + } + var sb strings.Builder + for _, r := range in { + switch { + case r >= 'a' && r <= 'z': + sb.WriteRune(r) + case r >= '0' && r <= '9': + sb.WriteRune(r) + case r == '-' || r == '_' || r == '.': + sb.WriteRune(r) + case r == ' ': + sb.WriteRune('-') + } + } + out := strings.Trim(sb.String(), "-_.") + if out == "" { + return "" + } + return out +} + +func normalizeAgentRunKind(kind string, worldDecision *WorldDecisionContext) string { + kind = strings.TrimSpace(kind) + if kind != "" { + return kind + } + if worldDecision != nil { + return "world_npc" + } + return "agent" +} + +func cloneOriginRef(in *OriginRef) *OriginRef { + if in == nil { + return nil + } + return &OriginRef{ + Channel: strings.TrimSpace(in.Channel), + ChatID: strings.TrimSpace(in.ChatID), + } +} + +func cloneTargetRef(in *TargetRef) *TargetRef { + if in == nil { + return nil + } + return &TargetRef{ + Transport: strings.TrimSpace(in.Transport), + NodeID: strings.TrimSpace(in.NodeID), + ParentAgentID: strings.TrimSpace(in.ParentAgentID), + } +} + +func mergeTargetRef(base, override *TargetRef) *TargetRef { + out := cloneTargetRef(base) + if out == nil { + out = &TargetRef{} + } + if override == nil { + return out + } + if v := strings.TrimSpace(override.Transport); v != "" { + out.Transport = v + } + if v := strings.TrimSpace(override.NodeID); v != "" { + out.NodeID = v + } + if v := strings.TrimSpace(override.ParentAgentID); v != "" { + out.ParentAgentID = v + } + return out +} + +func TargetTransport(target *TargetRef) string { + if target == nil { + return "" + } + return strings.TrimSpace(target.Transport) +} + +func TargetNodeID(target *TargetRef) string { + if target == nil { + return "" + } + return strings.TrimSpace(target.NodeID) +} + +func TargetParentAgentID(target *TargetRef) string { + if target == nil { + return "" + } + return strings.TrimSpace(target.ParentAgentID) +} + +func cloneExecutionPolicy(in *ExecutionPolicy) *ExecutionPolicy { + if in == nil { + return nil + } + return &ExecutionPolicy{ + PromptFile: strings.TrimSpace(in.PromptFile), + ToolAllowlist: append([]string(nil), in.ToolAllowlist...), + MaxRetries: in.MaxRetries, + RetryBackoff: in.RetryBackoff, + TimeoutSec: in.TimeoutSec, + MaxTaskChars: in.MaxTaskChars, + MaxResultChars: in.MaxResultChars, + } +} + +func mergeExecutionPolicy(base, override *ExecutionPolicy) *ExecutionPolicy { + out := cloneExecutionPolicy(base) + if out == nil { + out = &ExecutionPolicy{RetryBackoff: 1000} + } + if override == nil { + return out + } + if v := strings.TrimSpace(override.PromptFile); v != "" { + out.PromptFile = v + } + if len(override.ToolAllowlist) > 0 { + out.ToolAllowlist = append([]string(nil), override.ToolAllowlist...) + } + if override.MaxRetries > 0 { + out.MaxRetries = override.MaxRetries + } + if override.RetryBackoff > 0 { + out.RetryBackoff = override.RetryBackoff + } + if override.TimeoutSec > 0 { + out.TimeoutSec = override.TimeoutSec + } + if override.MaxTaskChars > 0 { + out.MaxTaskChars = override.MaxTaskChars + } + if override.MaxResultChars > 0 { + out.MaxResultChars = override.MaxResultChars + } + return out +} + +func OriginValues(in *OriginRef) (string, string) { + channel := "cli" + chatID := "direct" + if in == nil { + return channel, chatID + } + if v := strings.TrimSpace(in.Channel); v != "" { + channel = v + } + if v := strings.TrimSpace(in.ChatID); v != "" { + chatID = v + } + return channel, chatID +} + +func PromptFileFromPolicy(policy *ExecutionPolicy) string { + if policy == nil { + return "" + } + return strings.TrimSpace(policy.PromptFile) +} + +func ToolAllowlistFromPolicy(policy *ExecutionPolicy) []string { + if policy == nil || len(policy.ToolAllowlist) == 0 { + return nil + } + return append([]string(nil), policy.ToolAllowlist...) +} + +func maxRetriesFromPolicy(policy *ExecutionPolicy) int { + if policy == nil { + return 0 + } + return normalizePositiveBound(policy.MaxRetries, 0, 8) +} + +func retryBackoffFromPolicy(policy *ExecutionPolicy) int { + if policy == nil { + return 1000 + } + return normalizePositiveBound(policy.RetryBackoff, 500, 120000) +} + +func timeoutSecFromPolicy(policy *ExecutionPolicy) int { + if policy == nil { + return 0 + } + return normalizePositiveBound(policy.TimeoutSec, 0, 3600) +} + +func maxTaskCharsFromPolicy(policy *ExecutionPolicy) int { + if policy == nil { + return 0 + } + return normalizePositiveBound(policy.MaxTaskChars, 0, 400000) +} + +func maxResultCharsFromPolicy(policy *ExecutionPolicy) int { + if policy == nil { + return 0 + } + return normalizePositiveBound(policy.MaxResultChars, 0, 400000) +} + +func originChannelOrDefault(in *OriginRef) string { + channel, _ := OriginValues(in) + return channel +} + +func taskMailboxThreadID(task *AgentTask) string { + if task == nil { + return "" + } + return strings.TrimSpace(task.ID) +} + +func BuildAgentSessionKey(agentID, taskID string) string { + a := normalizeAgentIdentifier(agentID) + if a == "" { + a = "default" + } + t := normalizeAgentIdentifier(taskID) + if t == "" { + t = "task" + } + return fmt.Sprintf("agent:%s:%s", a, t) +} + +func firstNonEmptyMessageType(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "message" + } + return v +} + +func (sm *AgentManager) persistTaskLocked(task *AgentTask, eventType, message string) { + if task == nil || sm.runStore == nil { + return + } + cp := cloneAgentTask(task) + _ = sm.runStore.AppendRun(cp) + _ = sm.runStore.AppendEvent(AgentRunEvent{ + RunID: cp.ID, + AgentID: cp.AgentID, + Type: strings.TrimSpace(eventType), + Status: cp.Status, + Message: strings.TrimSpace(message), + RetryCount: cp.RetryCount, + At: cp.Updated, + }) +} + +func (sm *AgentManager) WaitTask(ctx context.Context, taskID string) (*AgentTask, bool, error) { + if sm == nil { + return nil, false, fmt.Errorf("agent manager not available") + } + taskID = strings.TrimSpace(taskID) + if taskID == "" { + return nil, false, fmt.Errorf("task id is required") + } + if ctx == nil { + ctx = context.Background() + } + ch := make(chan struct{}, 1) + sm.mu.Lock() + sm.pruneArchivedLocked() + task, ok := sm.tasks[taskID] + if !ok && sm.runStore != nil { + if persisted, found := sm.runStore.Get(taskID); found && persisted != nil { + if IsTerminalRuntimeStatus(persisted.Status) { + sm.mu.Unlock() + return persisted, true, nil + } + } + } + if ok && task != nil && IsTerminalRuntimeStatus(task.Status) { + cp := cloneAgentTask(task) + sm.mu.Unlock() + return cp, true, nil + } + waiters := sm.waiters[taskID] + if waiters == nil { + waiters = map[chan struct{}]struct{}{} + sm.waiters[taskID] = waiters + } + waiters[ch] = struct{}{} + sm.mu.Unlock() + + defer sm.removeTaskWaiter(taskID, ch) + for { + select { + case <-ctx.Done(): + return nil, false, ctx.Err() + case <-ch: + sm.mu.Lock() + sm.pruneArchivedLocked() + task, ok := sm.tasks[taskID] + if ok && task != nil && IsTerminalRuntimeStatus(task.Status) { + cp := cloneAgentTask(task) + 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) { + sm.mu.Unlock() + return persisted, true, nil + } + } + sm.mu.Unlock() + } + } +} + +func (sm *AgentManager) removeTaskWaiter(taskID string, ch chan struct{}) { + sm.mu.Lock() + defer sm.mu.Unlock() + waiters := sm.waiters[taskID] + if len(waiters) == 0 { + delete(sm.waiters, taskID) + return + } + delete(waiters, ch) + if len(waiters) == 0 { + delete(sm.waiters, taskID) + } +} + +func (sm *AgentManager) notifyTaskWaitersLocked(taskID string) { + waiters := sm.waiters[taskID] + if len(waiters) == 0 { + delete(sm.waiters, taskID) + return + } + for ch := range waiters { + select { + case ch <- struct{}{}: + default: + } + } + delete(sm.waiters, taskID) +} + +func (sm *AgentManager) recordMailboxMessageLocked(task *AgentTask, msg AgentMessage) { + if sm.mailboxStore == nil || task == nil { + return + } + if strings.TrimSpace(msg.ThreadID) == "" { + msg.ThreadID = taskMailboxThreadID(task) + } + stored, err := sm.mailboxStore.AppendMessage(msg) + if err != nil { + return + } + _ = stored +} + +func (sm *AgentManager) consumeThreadInbox(task *AgentTask) (string, []string) { + if task == nil || sm.mailboxStore == nil { + return "", nil + } + msgs, err := sm.mailboxStore.ThreadInbox(taskMailboxThreadID(task), task.AgentID, 0) + if err != nil || len(msgs) == 0 { + return "", nil + } + var sb strings.Builder + consumed := make([]string, 0, len(msgs)) + now := time.Now().UnixMilli() + for _, msg := range msgs { + if _, err := sm.mailboxStore.UpdateMessageStatus(msg.MessageID, "processing", now); err != nil { + continue + } + consumed = append(consumed, msg.MessageID) + sb.WriteString(fmt.Sprintf("- [%s] from=%s type=%s", msg.MessageID, msg.FromAgent, msg.Type)) + sb.WriteString("\n") + sb.WriteString(strings.TrimSpace(msg.Content)) + sb.WriteString("\n") + } + return strings.TrimSpace(sb.String()), consumed +} + +func (sm *AgentManager) restoreMessageStatuses(messageIDs []string) { + if sm.mailboxStore == nil || len(messageIDs) == 0 { + return + } + now := time.Now().UnixMilli() + for _, messageID := range messageIDs { + _, _ = sm.mailboxStore.UpdateMessageStatus(messageID, "queued", now) + } +} + +func (sm *AgentManager) ackMessageStatuses(messageIDs []string) { + if sm.mailboxStore == nil || len(messageIDs) == 0 { + return + } + now := time.Now().UnixMilli() + for _, messageID := range messageIDs { + _, _ = sm.mailboxStore.UpdateMessageStatus(messageID, "acked", now) + } +} diff --git a/pkg/tools/agent_dispatcher.go b/pkg/tools/agent_dispatcher.go new file mode 100644 index 0000000..2c834af --- /dev/null +++ b/pkg/tools/agent_dispatcher.go @@ -0,0 +1,109 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "time" +) + +type AgentDispatchRequest struct { + Task string + RunKind string + AgentID string + Decision *DispatchDecision + Target *TargetRef + Origin *OriginRef + ExecutionPolicy *ExecutionPolicy + WorldDecision *WorldDecisionContext +} + +type AgentDispatchReply struct { + TaskID string + AgentID string + Status string + Result string + Run RunRecord + Error *RuntimeError +} + +type AgentDispatcher struct { + manager *AgentManager +} + +func NewAgentDispatcher(manager *AgentManager) *AgentDispatcher { + return &AgentDispatcher{manager: manager} +} + +func (r *AgentDispatcher) DispatchTask(ctx context.Context, req AgentDispatchRequest) (*AgentTask, error) { + if r == nil || r.manager == nil { + return nil, fmt.Errorf("agent dispatcher is not configured") + } + if req.Decision != nil { + if strings.TrimSpace(req.AgentID) == "" { + req.AgentID = strings.TrimSpace(req.Decision.TargetAgent) + } + if strings.TrimSpace(req.Task) == "" { + req.Task = strings.TrimSpace(req.Decision.TaskText) + } + } + task, err := r.manager.SpawnTask(ctx, AgentSpawnOptions{ + Task: req.Task, + RunKind: req.RunKind, + AgentID: req.AgentID, + Target: req.Target, + Origin: req.Origin, + ExecutionPolicy: req.ExecutionPolicy, + WorldDecision: req.WorldDecision, + }) + if err != nil { + return nil, err + } + return task, nil +} + +func (r *AgentDispatcher) WaitReply(ctx context.Context, taskID string, interval time.Duration) (*AgentDispatchReply, error) { + if r == nil || r.manager == nil { + return nil, fmt.Errorf("agent dispatcher is not configured") + } + _ = interval + taskID = strings.TrimSpace(taskID) + if taskID == "" { + return nil, fmt.Errorf("task id is required") + } + task, ok, err := r.manager.WaitTask(ctx, taskID) + if err != nil { + return nil, err + } + if !ok || task == nil { + return nil, fmt.Errorf("agent not found") + } + return &AgentDispatchReply{ + TaskID: task.ID, + AgentID: task.AgentID, + Status: task.Status, + Result: strings.TrimSpace(task.Result), + Run: taskToRunRecord(task), + Error: taskRuntimeError(task), + }, nil +} + +func (r *AgentDispatcher) MergeResults(replies []*AgentDispatchReply) string { + if len(replies) == 0 { + return "" + } + var sb strings.Builder + for _, reply := range replies { + if reply == nil { + continue + } + sb.WriteString(fmt.Sprintf("[%s] agent=%s status=%s\n", reply.TaskID, reply.AgentID, reply.Status)) + if txt := strings.TrimSpace(reply.Result); txt != "" { + sb.WriteString(txt) + } else { + sb.WriteString("(empty result)") + } + sb.WriteString("\n\n") + } + return strings.TrimSpace(sb.String()) +} diff --git a/pkg/tools/agent_dispatcher_test.go b/pkg/tools/agent_dispatcher_test.go new file mode 100644 index 0000000..99a2280 --- /dev/null +++ b/pkg/tools/agent_dispatcher_test.go @@ -0,0 +1,73 @@ +package tools + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestAgentDispatcherDispatchAndWaitReply(t *testing.T) { + workspace := t.TempDir() + manager := NewAgentManager(nil, workspace, nil) + manager.SetRunFunc(func(ctx context.Context, task *AgentTask) (string, error) { + return "dispatch-result", nil + }) + dispatcher := NewAgentDispatcher(manager) + + task, err := dispatcher.DispatchTask(context.Background(), AgentDispatchRequest{ + Task: "implement feature", + AgentID: "coder", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, + }) + if err != nil { + t.Fatalf("dispatch failed: %v", err) + } + if strings.TrimSpace(task.ID) == "" { + t.Fatalf("expected task id on dispatched task") + } + + reply, err := dispatcher.WaitReply(context.Background(), task.ID, 20*time.Millisecond) + if err != nil { + t.Fatalf("wait reply failed: %v", err) + } + if reply.Status != "completed" || reply.Result != "dispatch-result" { + t.Fatalf("unexpected reply: %+v", reply) + } +} + +func TestAgentDispatcherMergeResults(t *testing.T) { + dispatcher := NewAgentDispatcher(nil) + out := dispatcher.MergeResults([]*AgentDispatchReply{ + {TaskID: "agent-1", AgentID: "coder", Status: "completed", Result: "done"}, + {TaskID: "agent-2", AgentID: "tester", Status: "failed", Result: "boom"}, + }) + if !strings.Contains(out, "agent-1") || !strings.Contains(out, "agent=tester") { + t.Fatalf("unexpected merged output: %s", out) + } +} + +func TestAgentDispatcherWaitReplyContextCancel(t *testing.T) { + workspace := t.TempDir() + manager := NewAgentManager(nil, workspace, nil) + manager.SetRunFunc(func(ctx context.Context, task *AgentTask) (string, error) { + <-ctx.Done() + return "", ctx.Err() + }) + dispatcher := NewAgentDispatcher(manager) + + task, err := dispatcher.DispatchTask(context.Background(), AgentDispatchRequest{ + Task: "long task", + AgentID: "coder", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, + }) + if err != nil { + t.Fatalf("dispatch failed: %v", err) + } + + waitCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if _, err := dispatcher.WaitReply(waitCtx, task.ID, 20*time.Millisecond); err == nil { + t.Fatalf("expected context cancellation error") + } +} diff --git a/pkg/tools/subagent_mailbox.go b/pkg/tools/agent_mailbox.go similarity index 51% rename from pkg/tools/subagent_mailbox.go rename to pkg/tools/agent_mailbox.go index b5c9974..5fc7587 100644 --- a/pkg/tools/subagent_mailbox.go +++ b/pkg/tools/agent_mailbox.go @@ -12,39 +12,24 @@ import ( "sync" ) -type AgentThread struct { - ThreadID string `json:"thread_id"` - Owner string `json:"owner"` - Participants []string `json:"participants,omitempty"` - Status string `json:"status"` - Topic string `json:"topic,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` -} - type AgentMessage struct { MessageID string `json:"message_id"` ThreadID string `json:"thread_id"` FromAgent string `json:"from_agent"` ToAgent string `json:"to_agent"` - ReplyTo string `json:"reply_to,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` Type string `json:"type"` Content string `json:"content"` - RequiresReply bool `json:"requires_reply,omitempty"` Status string `json:"status"` CreatedAt int64 `json:"created_at"` } type AgentMailboxStore struct { - dir string - threadsPath string - msgsPath string - mu sync.RWMutex - threads map[string]*AgentThread - messages map[string]*AgentMessage - msgSeq int - threadSeq int + dir string + msgsPath string + mu sync.RWMutex + messages map[string]*AgentMessage + msgSeq int + threadSeq int } func NewAgentMailboxStore(workspace string) *AgentMailboxStore { @@ -54,11 +39,9 @@ func NewAgentMailboxStore(workspace string) *AgentMailboxStore { } dir := filepath.Join(workspace, "agents", "runtime") s := &AgentMailboxStore{ - dir: dir, - threadsPath: filepath.Join(dir, "threads.jsonl"), - msgsPath: filepath.Join(dir, "agent_messages.jsonl"), - threads: map[string]*AgentThread{}, - messages: map[string]*AgentMessage{}, + dir: dir, + msgsPath: filepath.Join(dir, "agent_messages.jsonl"), + messages: map[string]*AgentMessage{}, } _ = os.MkdirAll(dir, 0755) _ = s.load() @@ -68,44 +51,10 @@ func NewAgentMailboxStore(workspace string) *AgentMailboxStore { func (s *AgentMailboxStore) load() error { s.mu.Lock() defer s.mu.Unlock() - s.threads = map[string]*AgentThread{} s.messages = map[string]*AgentMessage{} - if err := s.loadThreadsLocked(); err != nil { - return err - } return s.scanMessagesLocked() } -func (s *AgentMailboxStore) loadThreadsLocked() error { - f, err := os.Open(s.threadsPath) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - defer f.Close() - scanner := bufio.NewScanner(f) - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, 2*1024*1024) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - var thread AgentThread - if err := json.Unmarshal([]byte(line), &thread); err != nil { - continue - } - cp := thread - s.threads[thread.ThreadID] = &cp - if n := parseThreadSequence(thread.ThreadID); n > s.threadSeq { - s.threadSeq = n - } - } - return scanner.Err() -} - func (s *AgentMailboxStore) scanMessagesLocked() error { f, err := os.Open(s.msgsPath) if err != nil { @@ -130,56 +79,30 @@ func (s *AgentMailboxStore) scanMessagesLocked() error { if n := parseMessageSequence(msg.MessageID); n > s.msgSeq { s.msgSeq = n } + if n := parseThreadSequence(msg.ThreadID); n > s.threadSeq { + s.threadSeq = n + } cp := msg s.messages[msg.MessageID] = &cp - if thread := s.threads[msg.ThreadID]; thread != nil && msg.CreatedAt > thread.UpdatedAt { - thread.UpdatedAt = msg.CreatedAt - } } return scanner.Err() } -func (s *AgentMailboxStore) EnsureThread(thread AgentThread) (AgentThread, error) { +func (s *AgentMailboxStore) EnsureThreadID(threadID string) string { if s == nil { - return thread, nil + return strings.TrimSpace(threadID) } s.mu.Lock() defer s.mu.Unlock() - if err := os.MkdirAll(s.dir, 0755); err != nil { - return AgentThread{}, err + threadID = strings.TrimSpace(threadID) + if threadID != "" { + if n := parseThreadSequence(threadID); n > s.threadSeq { + s.threadSeq = n + } + return threadID } - if strings.TrimSpace(thread.ThreadID) == "" { - s.threadSeq++ - thread.ThreadID = fmt.Sprintf("thread-%04d", s.threadSeq) - } - thread.Participants = normalizeStringList(thread.Participants) - if strings.TrimSpace(thread.Status) == "" { - thread.Status = "open" - } - if thread.CreatedAt <= 0 { - thread.CreatedAt = thread.UpdatedAt - } - if thread.CreatedAt <= 0 { - thread.CreatedAt = 1 - } - if thread.UpdatedAt <= 0 { - thread.UpdatedAt = thread.CreatedAt - } - data, err := json.Marshal(thread) - if err != nil { - return AgentThread{}, err - } - f, err := os.OpenFile(s.threadsPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return AgentThread{}, err - } - defer f.Close() - if _, err := f.Write(append(data, '\n')); err != nil { - return AgentThread{}, err - } - cp := thread - s.threads[thread.ThreadID] = &cp - return thread, nil + s.threadSeq++ + return fmt.Sprintf("thread-%04d", s.threadSeq) } func (s *AgentMailboxStore) AppendMessage(msg AgentMessage) (AgentMessage, error) { @@ -210,41 +133,11 @@ func (s *AgentMailboxStore) AppendMessage(msg AgentMessage) (AgentMessage, error if _, err := f.Write(append(data, '\n')); err != nil { return AgentMessage{}, err } - if thread := s.threads[msg.ThreadID]; thread != nil { - thread.UpdatedAt = msg.CreatedAt - participants := append([]string(nil), thread.Participants...) - participants = append(participants, msg.FromAgent, msg.ToAgent) - thread.Participants = normalizeStringList(participants) - } cp := msg s.messages[msg.MessageID] = &cp return msg, nil } -func (s *AgentMailboxStore) Thread(threadID string) (*AgentThread, bool) { - if s == nil { - return nil, false - } - s.mu.RLock() - defer s.mu.RUnlock() - thread, ok := s.threads[strings.TrimSpace(threadID)] - if !ok { - return nil, false - } - cp := *thread - cp.Participants = append([]string(nil), thread.Participants...) - return &cp, true -} - -func (s *AgentMailboxStore) MessagesByThread(threadID string, limit int) ([]AgentMessage, error) { - if s == nil { - return nil, nil - } - return s.currentMessages(func(msg AgentMessage) bool { - return msg.ThreadID == strings.TrimSpace(threadID) - }, limit), nil -} - func (s *AgentMailboxStore) Inbox(agentID string, limit int) ([]AgentMessage, error) { if s == nil { return nil, nil @@ -337,47 +230,6 @@ func parseThreadSequence(threadID string) int { return n } -func threadToThreadRecord(thread *AgentThread) ThreadRecord { - if thread == nil { - return ThreadRecord{} - } - return ThreadRecord{ - ID: thread.ThreadID, - OwnerAgentID: thread.Owner, - Participants: append([]string(nil), thread.Participants...), - Status: thread.Status, - Topic: thread.Topic, - CreatedAt: thread.CreatedAt, - UpdatedAt: thread.UpdatedAt, - } -} - -func messageToArtifactRecord(msg AgentMessage) ArtifactRecord { - agentID := strings.TrimSpace(msg.FromAgent) - if agentID == "" { - agentID = strings.TrimSpace(msg.ToAgent) - } - return ArtifactRecord{ - ID: msg.MessageID, - RunID: msg.CorrelationID, - TaskID: msg.CorrelationID, - ThreadID: msg.ThreadID, - Kind: "message", - Name: msg.Type, - Content: msg.Content, - AgentID: agentID, - FromAgent: msg.FromAgent, - ToAgent: msg.ToAgent, - ReplyTo: msg.ReplyTo, - CorrelationID: msg.CorrelationID, - Status: msg.Status, - RequiresReply: msg.RequiresReply, - CreatedAt: msg.CreatedAt, - Visible: true, - SourceType: "agent_message", - } -} - func parseMessageSequence(messageID string) int { messageID = strings.TrimSpace(messageID) if !strings.HasPrefix(messageID, "msg-") { diff --git a/pkg/tools/subagent_profile.go b/pkg/tools/agent_profile.go similarity index 62% rename from pkg/tools/subagent_profile.go rename to pkg/tools/agent_profile.go index 5c88bc8..cb886bb 100644 --- a/pkg/tools/subagent_profile.go +++ b/pkg/tools/agent_profile.go @@ -16,15 +16,23 @@ import ( "github.com/YspCoder/clawgo/pkg/runtimecfg" ) -type SubagentProfile struct { +type AgentProfile struct { AgentID string `json:"agent_id"` Name string `json:"name"` + Kind string `json:"kind,omitempty"` Transport string `json:"transport,omitempty"` NodeID string `json:"node_id,omitempty"` ParentAgentID string `json:"parent_agent_id,omitempty"` - NotifyMainPolicy string `json:"notify_main_policy,omitempty"` Role string `json:"role,omitempty"` - SystemPromptFile string `json:"system_prompt_file,omitempty"` + Persona string `json:"persona,omitempty"` + Traits []string `json:"traits,omitempty"` + Faction string `json:"faction,omitempty"` + HomeLocation string `json:"home_location,omitempty"` + DefaultGoals []string `json:"default_goals,omitempty"` + PerceptionScope int `json:"perception_scope,omitempty"` + ScheduleHint string `json:"schedule_hint,omitempty"` + WorldTags []string `json:"world_tags,omitempty"` + PromptFile string `json:"prompt_file,omitempty"` ToolAllowlist []string `json:"tool_allowlist,omitempty"` MemoryNamespace string `json:"memory_namespace,omitempty"` MaxRetries int `json:"max_retries,omitempty"` @@ -38,25 +46,25 @@ type SubagentProfile struct { ManagedBy string `json:"managed_by,omitempty"` } -type SubagentProfileStore struct { +type AgentProfileStore struct { workspace string mu sync.RWMutex } -func NewSubagentProfileStore(workspace string) *SubagentProfileStore { - return &SubagentProfileStore{workspace: strings.TrimSpace(workspace)} +func NewAgentProfileStore(workspace string) *AgentProfileStore { + return &AgentProfileStore{workspace: strings.TrimSpace(workspace)} } -func (s *SubagentProfileStore) profilesDir() string { +func (s *AgentProfileStore) profilesDir() string { return filepath.Join(s.workspace, "agents", "profiles") } -func (s *SubagentProfileStore) profilePath(agentID string) string { - id := normalizeSubagentIdentifier(agentID) +func (s *AgentProfileStore) profilePath(agentID string) string { + id := normalizeAgentIdentifier(agentID) return filepath.Join(s.profilesDir(), id+".json") } -func (s *SubagentProfileStore) List() ([]SubagentProfile, error) { +func (s *AgentProfileStore) List() ([]AgentProfile, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -64,7 +72,7 @@ func (s *SubagentProfileStore) List() ([]SubagentProfile, error) { if err != nil { return nil, err } - out := make([]SubagentProfile, 0, len(merged)) + out := make([]AgentProfile, 0, len(merged)) for _, p := range merged { out = append(out, p) } @@ -77,8 +85,8 @@ func (s *SubagentProfileStore) List() ([]SubagentProfile, error) { return out, nil } -func (s *SubagentProfileStore) Get(agentID string) (*SubagentProfile, bool, error) { - id := normalizeSubagentIdentifier(agentID) +func (s *AgentProfileStore) Get(agentID string) (*AgentProfile, bool, error) { + id := normalizeAgentIdentifier(agentID) if id == "" { return nil, false, nil } @@ -97,7 +105,7 @@ func (s *SubagentProfileStore) Get(agentID string) (*SubagentProfile, bool, erro return &cp, true, nil } -func (s *SubagentProfileStore) FindByRole(role string) (*SubagentProfile, bool, error) { +func (s *AgentProfileStore) FindByRole(role string) (*AgentProfile, bool, error) { target := strings.ToLower(strings.TrimSpace(role)) if target == "" { return nil, false, nil @@ -115,8 +123,8 @@ func (s *SubagentProfileStore) FindByRole(role string) (*SubagentProfile, bool, return nil, false, nil } -func (s *SubagentProfileStore) Upsert(profile SubagentProfile) (*SubagentProfile, error) { - p := normalizeSubagentProfile(profile) +func (s *AgentProfileStore) Upsert(profile AgentProfile) (*AgentProfile, error) { + p := normalizeAgentProfile(profile) if p.AgentID == "" { return nil, fmt.Errorf("agent_id is required") } @@ -124,19 +132,19 @@ func (s *SubagentProfileStore) Upsert(profile SubagentProfile) (*SubagentProfile s.mu.Lock() defer s.mu.Unlock() if managed, ok := s.configProfileLocked(p.AgentID); ok { - return nil, fmt.Errorf("subagent profile %q is managed by %s", p.AgentID, managed.ManagedBy) + return nil, fmt.Errorf("agent profile %q is managed by %s", p.AgentID, managed.ManagedBy) } if managed, ok := s.nodeProfileLocked(p.AgentID); ok { - return nil, fmt.Errorf("subagent profile %q is managed by %s", p.AgentID, managed.ManagedBy) + return nil, fmt.Errorf("agent profile %q is managed by %s", p.AgentID, managed.ManagedBy) } now := time.Now().UnixMilli() path := s.profilePath(p.AgentID) - existing := SubagentProfile{} + existing := AgentProfile{} if b, err := os.ReadFile(path); err == nil { _ = json.Unmarshal(b, &existing) } - existing = normalizeSubagentProfile(existing) + existing = normalizeAgentProfile(existing) if existing.CreatedAt > 0 { p.CreatedAt = existing.CreatedAt } else if p.CreatedAt <= 0 { @@ -157,18 +165,18 @@ func (s *SubagentProfileStore) Upsert(profile SubagentProfile) (*SubagentProfile return &p, nil } -func (s *SubagentProfileStore) Delete(agentID string) error { - id := normalizeSubagentIdentifier(agentID) +func (s *AgentProfileStore) Delete(agentID string) error { + id := normalizeAgentIdentifier(agentID) if id == "" { return fmt.Errorf("agent_id is required") } s.mu.Lock() defer s.mu.Unlock() if managed, ok := s.configProfileLocked(id); ok { - return fmt.Errorf("subagent profile %q is managed by %s", id, managed.ManagedBy) + return fmt.Errorf("agent profile %q is managed by %s", id, managed.ManagedBy) } if managed, ok := s.nodeProfileLocked(id); ok { - return fmt.Errorf("subagent profile %q is managed by %s", id, managed.ManagedBy) + return fmt.Errorf("agent profile %q is managed by %s", id, managed.ManagedBy) } err := os.Remove(s.profilePath(id)) @@ -178,20 +186,28 @@ func (s *SubagentProfileStore) Delete(agentID string) error { return nil } -func normalizeSubagentProfile(in SubagentProfile) SubagentProfile { +func normalizeAgentProfile(in AgentProfile) AgentProfile { p := in - p.AgentID = normalizeSubagentIdentifier(p.AgentID) + p.AgentID = normalizeAgentIdentifier(p.AgentID) p.Name = strings.TrimSpace(p.Name) if p.Name == "" { p.Name = p.AgentID } + p.Kind = normalizeProfileKind(p.Kind) p.Transport = normalizeProfileTransport(p.Transport) p.NodeID = strings.TrimSpace(p.NodeID) - p.ParentAgentID = normalizeSubagentIdentifier(p.ParentAgentID) - p.NotifyMainPolicy = normalizeNotifyMainPolicy(p.NotifyMainPolicy) + p.ParentAgentID = normalizeAgentIdentifier(p.ParentAgentID) p.Role = strings.TrimSpace(p.Role) - p.SystemPromptFile = strings.TrimSpace(p.SystemPromptFile) - p.MemoryNamespace = normalizeSubagentIdentifier(p.MemoryNamespace) + p.Persona = strings.TrimSpace(p.Persona) + p.Traits = normalizeStringList(p.Traits) + p.Faction = strings.TrimSpace(p.Faction) + p.HomeLocation = normalizeAgentIdentifier(p.HomeLocation) + p.DefaultGoals = normalizeStringList(p.DefaultGoals) + p.PerceptionScope = clampInt(p.PerceptionScope, 0, 10) + p.ScheduleHint = strings.TrimSpace(p.ScheduleHint) + p.WorldTags = normalizeStringList(p.WorldTags) + p.PromptFile = strings.TrimSpace(p.PromptFile) + p.MemoryNamespace = normalizeAgentIdentifier(p.MemoryNamespace) if p.MemoryNamespace == "" { p.MemoryNamespace = p.AgentID } @@ -227,6 +243,17 @@ func normalizeProfileTransport(s string) string { } } +func normalizeProfileKind(s string) string { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "npc": + return "npc" + case "agent", "tool": + return strings.ToLower(strings.TrimSpace(s)) + default: + return "npc" + } +} + func normalizeStringList(in []string) []string { if len(in) == 0 { return nil @@ -272,8 +299,8 @@ func parseStringList(raw interface{}) []string { return normalizeStringList(MapStringListArg(map[string]interface{}{"items": raw}, "items")) } -func (s *SubagentProfileStore) mergedProfilesLocked() (map[string]SubagentProfile, error) { - merged := make(map[string]SubagentProfile) +func (s *AgentProfileStore) mergedProfilesLocked() (map[string]AgentProfile, error) { + merged := make(map[string]AgentProfile) for _, p := range s.configProfilesLocked() { merged[p.AgentID] = p } @@ -296,16 +323,16 @@ func (s *SubagentProfileStore) mergedProfilesLocked() (map[string]SubagentProfil return merged, nil } -func (s *SubagentProfileStore) fileProfilesLocked() ([]SubagentProfile, error) { +func (s *AgentProfileStore) fileProfilesLocked() ([]AgentProfile, error) { dir := s.profilesDir() entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { - return []SubagentProfile{}, nil + return []AgentProfile{}, nil } return nil, err } - out := make([]SubagentProfile, 0, len(entries)) + out := make([]AgentProfile, 0, len(entries)) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".json") { continue @@ -315,22 +342,22 @@ func (s *SubagentProfileStore) fileProfilesLocked() ([]SubagentProfile, error) { if err != nil { continue } - var p SubagentProfile + var p AgentProfile if err := json.Unmarshal(b, &p); err != nil { continue } - out = append(out, normalizeSubagentProfile(p)) + out = append(out, normalizeAgentProfile(p)) } return out, nil } -func (s *SubagentProfileStore) configProfilesLocked() []SubagentProfile { +func (s *AgentProfileStore) configProfilesLocked() []AgentProfile { cfg := runtimecfg.Get() - if cfg == nil || len(cfg.Agents.Subagents) == 0 { + if cfg == nil || len(cfg.Agents.Agents) == 0 { return nil } - out := make([]SubagentProfile, 0, len(cfg.Agents.Subagents)) - for agentID, subcfg := range cfg.Agents.Subagents { + out := make([]AgentProfile, 0, len(cfg.Agents.Agents)) + for agentID, subcfg := range cfg.Agents.Agents { profile := profileFromConfig(agentID, subcfg) if profile.AgentID == "" { continue @@ -340,34 +367,30 @@ func (s *SubagentProfileStore) configProfilesLocked() []SubagentProfile { return out } -func (s *SubagentProfileStore) configProfileLocked(agentID string) (SubagentProfile, bool) { - id := normalizeSubagentIdentifier(agentID) +func (s *AgentProfileStore) configProfileLocked(agentID string) (AgentProfile, bool) { + id := normalizeAgentIdentifier(agentID) if id == "" { - return SubagentProfile{}, false + return AgentProfile{}, false } cfg := runtimecfg.Get() if cfg == nil { - return SubagentProfile{}, false + return AgentProfile{}, false } - subcfg, ok := cfg.Agents.Subagents[id] + subcfg, ok := cfg.Agents.Agents[id] if !ok { - return SubagentProfile{}, false + return AgentProfile{}, false } return profileFromConfig(id, subcfg), true } -func (s *SubagentProfileStore) nodeProfileLocked(agentID string) (SubagentProfile, bool) { - id := normalizeSubagentIdentifier(agentID) +func (s *AgentProfileStore) nodeProfileLocked(agentID string) (AgentProfile, bool) { + id := normalizeAgentIdentifier(agentID) if id == "" { - return SubagentProfile{}, false + return AgentProfile{}, false } cfg := runtimecfg.Get() parentAgentID := "main" - if cfg != nil { - if mainID := normalizeSubagentIdentifier(cfg.Agents.Router.MainAgentID); mainID != "" { - parentAgentID = mainID - } - } + _ = cfg for _, node := range nodes.DefaultManager().List() { if isLocalNode(node.ID) { continue @@ -378,23 +401,31 @@ func (s *SubagentProfileStore) nodeProfileLocked(agentID string) (SubagentProfil } } } - return SubagentProfile{}, false + return AgentProfile{}, false } -func profileFromConfig(agentID string, subcfg config.SubagentConfig) SubagentProfile { +func profileFromConfig(agentID string, subcfg config.AgentConfig) AgentProfile { status := "active" if !subcfg.Enabled { status = "disabled" } - return normalizeSubagentProfile(SubagentProfile{ + return normalizeAgentProfile(AgentProfile{ AgentID: agentID, Name: strings.TrimSpace(subcfg.DisplayName), + Kind: strings.TrimSpace(subcfg.Kind), Transport: strings.TrimSpace(subcfg.Transport), NodeID: strings.TrimSpace(subcfg.NodeID), ParentAgentID: strings.TrimSpace(subcfg.ParentAgentID), - NotifyMainPolicy: strings.TrimSpace(subcfg.NotifyMainPolicy), Role: strings.TrimSpace(subcfg.Role), - SystemPromptFile: strings.TrimSpace(subcfg.SystemPromptFile), + Persona: strings.TrimSpace(subcfg.Persona), + Traits: append([]string(nil), subcfg.Traits...), + Faction: strings.TrimSpace(subcfg.Faction), + HomeLocation: strings.TrimSpace(subcfg.HomeLocation), + DefaultGoals: append([]string(nil), subcfg.DefaultGoals...), + PerceptionScope: subcfg.PerceptionScope, + ScheduleHint: strings.TrimSpace(subcfg.ScheduleHint), + WorldTags: append([]string(nil), subcfg.WorldTags...), + PromptFile: strings.TrimSpace(subcfg.PromptFile), ToolAllowlist: append([]string(nil), subcfg.Tools.Allowlist...), MemoryNamespace: strings.TrimSpace(subcfg.MemoryNamespace), MaxRetries: subcfg.Runtime.MaxRetries, @@ -407,19 +438,15 @@ func profileFromConfig(agentID string, subcfg config.SubagentConfig) SubagentPro }) } -func (s *SubagentProfileStore) nodeProfilesLocked() []SubagentProfile { +func (s *AgentProfileStore) nodeProfilesLocked() []AgentProfile { nodeItems := nodes.DefaultManager().List() if len(nodeItems) == 0 { return nil } cfg := runtimecfg.Get() parentAgentID := "main" - if cfg != nil { - if mainID := normalizeSubagentIdentifier(cfg.Agents.Router.MainAgentID); mainID != "" { - parentAgentID = mainID - } - } - out := make([]SubagentProfile, 0, len(nodeItems)) + _ = cfg + out := make([]AgentProfile, 0, len(nodeItems)) for _, node := range nodeItems { if isLocalNode(node.ID) { continue @@ -435,7 +462,7 @@ func (s *SubagentProfileStore) nodeProfilesLocked() []SubagentProfile { return out } -func profilesFromNode(node nodes.NodeInfo, parentAgentID string) []SubagentProfile { +func profilesFromNode(node nodes.NodeInfo, parentAgentID string) []AgentProfile { name := strings.TrimSpace(node.Name) if name == "" { name = strings.TrimSpace(node.ID) @@ -448,7 +475,7 @@ func profilesFromNode(node nodes.NodeInfo, parentAgentID string) []SubagentProfi if rootAgentID == "" { return nil } - out := []SubagentProfile{normalizeSubagentProfile(SubagentProfile{ + out := []AgentProfile{normalizeAgentProfile(AgentProfile{ AgentID: rootAgentID, Name: name + " Main Agent", Transport: "node", @@ -460,11 +487,11 @@ func profilesFromNode(node nodes.NodeInfo, parentAgentID string) []SubagentProfi ManagedBy: "node_registry", })} for _, agent := range node.Agents { - agentID := normalizeSubagentIdentifier(agent.ID) + agentID := normalizeAgentIdentifier(agent.ID) if agentID == "" || agentID == "main" { continue } - out = append(out, normalizeSubagentProfile(SubagentProfile{ + out = append(out, normalizeAgentProfile(AgentProfile{ AgentID: nodeChildAgentID(node.ID, agentID), Name: nodeChildAgentDisplayName(name, agent), Transport: "node", @@ -480,7 +507,7 @@ func profilesFromNode(node nodes.NodeInfo, parentAgentID string) []SubagentProfi } func nodeBranchAgentID(nodeID string) string { - id := normalizeSubagentIdentifier(nodeID) + id := normalizeAgentIdentifier(nodeID) if id == "" { return "" } @@ -488,8 +515,8 @@ func nodeBranchAgentID(nodeID string) string { } func nodeChildAgentID(nodeID, agentID string) string { - nodeID = normalizeSubagentIdentifier(nodeID) - agentID = normalizeSubagentIdentifier(agentID) + nodeID = normalizeAgentIdentifier(nodeID) + agentID = normalizeAgentIdentifier(agentID) if nodeID == "" || agentID == "" { return "" } @@ -509,36 +536,44 @@ func nodeChildAgentDisplayName(nodeName string, agent nodes.AgentInfo) string { } func isLocalNode(nodeID string) bool { - return normalizeSubagentIdentifier(nodeID) == "local" + return normalizeAgentIdentifier(nodeID) == "local" } -type SubagentProfileTool struct { - store *SubagentProfileStore +type AgentProfileTool struct { + store *AgentProfileStore } -func NewSubagentProfileTool(store *SubagentProfileStore) *SubagentProfileTool { - return &SubagentProfileTool{store: store} +func NewAgentProfileTool(store *AgentProfileStore) *AgentProfileTool { + return &AgentProfileTool{store: store} } -func (t *SubagentProfileTool) Name() string { return "subagent_profile" } +func (t *AgentProfileTool) Name() string { return "agent_profile" } -func (t *SubagentProfileTool) Description() string { - return "Manage subagent profiles: create/list/get/update/enable/disable/delete." +func (t *AgentProfileTool) Description() string { + return "Manage agent profiles: create/list/get/update/enable/disable/delete." } -func (t *SubagentProfileTool) Parameters() map[string]interface{} { +func (t *AgentProfileTool) Parameters() map[string]interface{} { return map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "action": map[string]interface{}{"type": "string", "description": "create|list|get|update|enable|disable|delete"}, "agent_id": map[string]interface{}{ "type": "string", - "description": "Unique subagent id, e.g. coder/writer/tester", + "description": "Unique agent id, e.g. coder/writer/tester", }, "name": map[string]interface{}{"type": "string"}, - "notify_main_policy": map[string]interface{}{"type": "string", "description": "final_only|internal_only|milestone|on_blocked|always"}, + "kind": map[string]interface{}{"type": "string", "description": "agent|npc|tool"}, "role": map[string]interface{}{"type": "string"}, - "system_prompt_file": map[string]interface{}{"type": "string"}, + "persona": map[string]interface{}{"type": "string"}, + "traits": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}}, + "faction": map[string]interface{}{"type": "string"}, + "home_location": map[string]interface{}{"type": "string"}, + "default_goals": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}}, + "perception_scope": map[string]interface{}{"type": "integer"}, + "schedule_hint": map[string]interface{}{"type": "string"}, + "world_tags": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}}, + "prompt_file": map[string]interface{}{"type": "string"}, "memory_namespace": map[string]interface{}{"type": "string"}, "status": map[string]interface{}{"type": "string", "description": "active|disabled"}, "tool_allowlist": map[string]interface{}{ @@ -546,7 +581,7 @@ func (t *SubagentProfileTool) Parameters() map[string]interface{} { "description": "Tool allowlist entries. Supports tool names, '*'/'all', and grouped tokens like 'group:files_read'.", "items": map[string]interface{}{"type": "string"}, }, - "max_retries": map[string]interface{}{"type": "integer", "description": "Retry limit for subagent task execution."}, + "max_retries": map[string]interface{}{"type": "integer", "description": "Retry limit for agent task execution."}, "retry_backoff_ms": map[string]interface{}{"type": "integer", "description": "Backoff between retries in milliseconds."}, "timeout_sec": map[string]interface{}{"type": "integer", "description": "Per-attempt timeout in seconds."}, "max_task_chars": map[string]interface{}{"type": "integer", "description": "Task input size quota (characters)."}, @@ -556,25 +591,25 @@ func (t *SubagentProfileTool) Parameters() map[string]interface{} { } } -func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { +func (t *AgentProfileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { _ = ctx if t.store == nil { - return "subagent profile store not available", nil + return "agent profile store not available", nil } action := strings.ToLower(MapStringArg(args, "action")) - agentID := normalizeSubagentIdentifier(MapStringArg(args, "agent_id")) - type subagentProfileActionHandler func() (string, error) - handlers := map[string]subagentProfileActionHandler{ + agentID := normalizeAgentIdentifier(MapStringArg(args, "agent_id")) + type agentProfileActionHandler func() (string, error) + handlers := map[string]agentProfileActionHandler{ "list": func() (string, error) { items, err := t.store.List() if err != nil { return "", err } if len(items) == 0 { - return "No subagent profiles.", nil + return "No agent profiles.", nil } var sb strings.Builder - sb.WriteString("Subagent Profiles:\n") + sb.WriteString("Agent Profiles:\n") for i, p := range items { sb.WriteString(fmt.Sprintf("- #%d %s [%s] role=%s memory_ns=%s\n", i+1, p.AgentID, p.Status, p.Role, p.MemoryNamespace)) } @@ -589,7 +624,7 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter return "", err } if !ok { - return "subagent profile not found", nil + return "agent profile not found", nil } b, _ := json.MarshalIndent(p, "", " ") return string(b), nil @@ -601,14 +636,22 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter if _, ok, err := t.store.Get(agentID); err != nil { return "", err } else if ok { - return "subagent profile already exists", nil + return "agent profile already exists", nil } - p := SubagentProfile{ + p := AgentProfile{ AgentID: agentID, Name: stringArg(args, "name"), - NotifyMainPolicy: stringArg(args, "notify_main_policy"), + Kind: stringArg(args, "kind"), Role: stringArg(args, "role"), - SystemPromptFile: stringArg(args, "system_prompt_file"), + Persona: stringArg(args, "persona"), + Traits: parseStringList(args["traits"]), + Faction: stringArg(args, "faction"), + HomeLocation: stringArg(args, "home_location"), + DefaultGoals: parseStringList(args["default_goals"]), + PerceptionScope: profileIntArg(args, "perception_scope"), + ScheduleHint: stringArg(args, "schedule_hint"), + WorldTags: parseStringList(args["world_tags"]), + PromptFile: stringArg(args, "prompt_file"), MemoryNamespace: stringArg(args, "memory_namespace"), Status: stringArg(args, "status"), ToolAllowlist: parseStringList(args["tool_allowlist"]), @@ -622,7 +665,7 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter if err != nil { return "", err } - return fmt.Sprintf("Created subagent profile: %s (role=%s status=%s)", saved.AgentID, saved.Role, saved.Status), nil + return fmt.Sprintf("Created agent profile: %s (role=%s status=%s)", saved.AgentID, saved.Role, saved.Status), nil }, "update": func() (string, error) { if agentID == "" { @@ -633,7 +676,7 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter return "", err } if !ok { - return "subagent profile not found", nil + return "agent profile not found", nil } next := *existing if _, ok := args["name"]; ok { @@ -642,11 +685,35 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter if _, ok := args["role"]; ok { next.Role = stringArg(args, "role") } - if _, ok := args["notify_main_policy"]; ok { - next.NotifyMainPolicy = stringArg(args, "notify_main_policy") + if _, ok := args["kind"]; ok { + next.Kind = stringArg(args, "kind") } - if _, ok := args["system_prompt_file"]; ok { - next.SystemPromptFile = stringArg(args, "system_prompt_file") + if _, ok := args["persona"]; ok { + next.Persona = stringArg(args, "persona") + } + if _, ok := args["traits"]; ok { + next.Traits = parseStringList(args["traits"]) + } + if _, ok := args["faction"]; ok { + next.Faction = stringArg(args, "faction") + } + if _, ok := args["home_location"]; ok { + next.HomeLocation = stringArg(args, "home_location") + } + if _, ok := args["default_goals"]; ok { + next.DefaultGoals = parseStringList(args["default_goals"]) + } + if _, ok := args["perception_scope"]; ok { + next.PerceptionScope = profileIntArg(args, "perception_scope") + } + if _, ok := args["schedule_hint"]; ok { + next.ScheduleHint = stringArg(args, "schedule_hint") + } + if _, ok := args["world_tags"]; ok { + next.WorldTags = parseStringList(args["world_tags"]) + } + if _, ok := args["prompt_file"]; ok { + next.PromptFile = stringArg(args, "prompt_file") } if _, ok := args["memory_namespace"]; ok { next.MemoryNamespace = stringArg(args, "memory_namespace") @@ -676,7 +743,7 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter if err != nil { return "", err } - return fmt.Sprintf("Updated subagent profile: %s (role=%s status=%s)", saved.AgentID, saved.Role, saved.Status), nil + return fmt.Sprintf("Updated agent profile: %s (role=%s status=%s)", saved.AgentID, saved.Role, saved.Status), nil }, "enable": func() (string, error) { if agentID == "" { @@ -687,14 +754,14 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter return "", err } if !ok { - return "subagent profile not found", nil + return "agent profile not found", nil } existing.Status = "active" saved, err := t.store.Upsert(*existing) if err != nil { return "", err } - return fmt.Sprintf("Subagent profile %s set to %s", saved.AgentID, saved.Status), nil + return fmt.Sprintf("Agent profile %s set to %s", saved.AgentID, saved.Status), nil }, "disable": func() (string, error) { if agentID == "" { @@ -705,14 +772,14 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter return "", err } if !ok { - return "subagent profile not found", nil + return "agent profile not found", nil } existing.Status = "disabled" saved, err := t.store.Upsert(*existing) if err != nil { return "", err } - return fmt.Sprintf("Subagent profile %s set to %s", saved.AgentID, saved.Status), nil + return fmt.Sprintf("Agent profile %s set to %s", saved.AgentID, saved.Status), nil }, "delete": func() (string, error) { if agentID == "" { @@ -721,7 +788,7 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter if err := t.store.Delete(agentID); err != nil { return "", err } - return fmt.Sprintf("Deleted subagent profile: %s", agentID), nil + return fmt.Sprintf("Deleted agent profile: %s", agentID), nil }, } if handler := handlers[action]; handler != nil { diff --git a/pkg/tools/subagent_profile_test.go b/pkg/tools/agent_profile_test.go similarity index 65% rename from pkg/tools/subagent_profile_test.go rename to pkg/tools/agent_profile_test.go index 18e16cd..9cac17e 100644 --- a/pkg/tools/subagent_profile_test.go +++ b/pkg/tools/agent_profile_test.go @@ -11,9 +11,9 @@ import ( "github.com/YspCoder/clawgo/pkg/runtimecfg" ) -func TestSubagentProfileStoreNormalization(t *testing.T) { - store := NewSubagentProfileStore(t.TempDir()) - saved, err := store.Upsert(SubagentProfile{ +func TestAgentProfileStoreNormalization(t *testing.T) { + store := NewAgentProfileStore(t.TempDir()) + saved, err := store.Upsert(AgentProfile{ AgentID: "Coder Agent", Name: " ", Role: "coding", @@ -44,25 +44,25 @@ func TestSubagentProfileStoreNormalization(t *testing.T) { } } -func TestSubagentProfileToolCreateParsesStringNumericArgs(t *testing.T) { - store := NewSubagentProfileStore(t.TempDir()) - tool := NewSubagentProfileTool(store) +func TestAgentProfileToolCreateParsesStringNumericArgs(t *testing.T) { + store := NewAgentProfileStore(t.TempDir()) + tool := NewAgentProfileTool(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", + "action": "create", + "agent_id": "reviewer", + "role": "testing", + "status": "active", + "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") { + if !strings.Contains(out, "Created agent profile") { t.Fatalf("unexpected output: %s", out) } @@ -78,42 +78,41 @@ func TestSubagentProfileToolCreateParsesStringNumericArgs(t *testing.T) { } } -func TestSubagentManagerSpawnRejectsDisabledProfile(t *testing.T) { +func TestAgentManagerSpawnRejectsDisabledProfile(t *testing.T) { workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + manager := NewAgentManager(nil, workspace, nil) + manager.SetRunFunc(func(ctx context.Context, task *AgentTask) (string, error) { return "ok", nil }) store := manager.ProfileStore() if store == nil { t.Fatalf("expected profile store to be available") } - if _, err := store.Upsert(SubagentProfile{ + if _, err := store.Upsert(AgentProfile{ AgentID: "writer", Status: "disabled", }); err != nil { t.Fatalf("failed to seed profile: %v", err) } - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ + _, err := manager.Spawn(context.Background(), AgentSpawnOptions{ Task: "Write docs", AgentID: "writer", - OriginChannel: "cli", - OriginChatID: "direct", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, }) if err == nil { t.Fatalf("expected disabled profile to block spawn") } } -func TestSubagentManagerSpawnResolvesProfileByRole(t *testing.T) { +func TestAgentManagerSpawnResolvesProfileByRole(t *testing.T) { workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) + manager := NewAgentManager(nil, workspace, nil) store := manager.ProfileStore() if store == nil { t.Fatalf("expected profile store to be available") } - if _, err := store.Upsert(SubagentProfile{ + if _, err := store.Upsert(AgentProfile{ AgentID: "coder", Role: "coding", Status: "active", @@ -122,11 +121,10 @@ func TestSubagentManagerSpawnResolvesProfileByRole(t *testing.T) { t.Fatalf("failed to seed profile: %v", err) } - _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ + _, err := manager.Spawn(context.Background(), AgentSpawnOptions{ Task: "Implement feature", Role: "coding", - OriginChannel: "cli", - OriginChatID: "direct", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, }) if err != nil { t.Fatalf("spawn failed: %v", err) @@ -140,32 +138,30 @@ func TestSubagentManagerSpawnResolvesProfileByRole(t *testing.T) { 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) + allowlist := ToolAllowlistFromPolicy(task.ExecutionPolicy) + if len(allowlist) != 1 || allowlist[0] != "read_file" { + t.Fatalf("expected allowlist from profile, got: %v", allowlist) } - 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) + _ = waitAgentDone(t, manager, 4*time.Second) } -func TestSubagentProfileStoreReadsProfilesFromRuntimeConfig(t *testing.T) { +func TestAgentProfileStoreReadsProfilesFromRuntimeConfig(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{ + cfg.Agents.Agents["coder"] = config.AgentConfig{ + Enabled: true, + DisplayName: "Code Agent", + Role: "coding", + PromptFile: "agents/coder/AGENT.md", + MemoryNamespace: "code-ns", + Tools: config.AgentToolsConfig{ Allowlist: []string{"read_file", "shell"}, }, - Runtime: config.SubagentRuntimeConfig{ + Runtime: config.AgentRuntimeConfig{ MaxRetries: 2, RetryBackoffMs: 2000, TimeoutSec: 120, @@ -175,7 +171,7 @@ func TestSubagentProfileStoreReadsProfilesFromRuntimeConfig(t *testing.T) { } runtimecfg.Set(cfg) - store := NewSubagentProfileStore(t.TempDir()) + store := NewAgentProfileStore(t.TempDir()) profile, ok, err := store.Get("coder") if err != nil { t.Fatalf("get failed: %v", err) @@ -189,30 +185,30 @@ func TestSubagentProfileStoreReadsProfilesFromRuntimeConfig(t *testing.T) { 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 profile.PromptFile != "agents/coder/AGENT.md" { + t.Fatalf("expected prompt_file from config, got: %s", profile.PromptFile) } if len(profile.ToolAllowlist) != 2 { t.Fatalf("expected merged allowlist, got: %v", profile.ToolAllowlist) } } -func TestSubagentProfileStoreRejectsWritesForConfigManagedProfiles(t *testing.T) { +func TestAgentProfileStoreRejectsWritesForConfigManagedProfiles(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", + cfg.Agents.Agents["tester"] = config.AgentConfig{ + Enabled: true, + Role: "test", + PromptFile: "agents/tester/AGENT.md", } runtimecfg.Set(cfg) - store := NewSubagentProfileStore(t.TempDir()) - if _, err := store.Upsert(SubagentProfile{AgentID: "tester"}); err == nil { + store := NewAgentProfileStore(t.TempDir()) + if _, err := store.Upsert(AgentProfile{AgentID: "tester"}); err == nil { t.Fatalf("expected config-managed upsert to fail") } if err := store.Delete("tester"); err == nil { @@ -220,7 +216,7 @@ func TestSubagentProfileStoreRejectsWritesForConfigManagedProfiles(t *testing.T) } } -func TestSubagentProfileStoreIncludesNodeMainBranchProfiles(t *testing.T) { +func TestAgentProfileStoreIncludesNodeMainBranchProfiles(t *testing.T) { runtimecfg.Set(config.DefaultConfig()) t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) @@ -228,12 +224,10 @@ func TestSubagentProfileStoreIncludesNodeMainBranchProfiles(t *testing.T) { }) 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", + cfg.Agents.Agents["main"] = config.AgentConfig{ + Enabled: true, + Type: "agent", + PromptFile: "agents/main/AGENT.md", } runtimecfg.Set(cfg) @@ -242,15 +236,15 @@ func TestSubagentProfileStoreIncludesNodeMainBranchProfiles(t *testing.T) { 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"}, + {ID: "main", DisplayName: "Main Agent", Role: "orchestrator", Type: "agent"}, + {ID: "coder", DisplayName: "Code Agent", Role: "code", Type: "agent"}, }, Capabilities: nodes.Capabilities{ Model: true, }, }) - store := NewSubagentProfileStore(t.TempDir()) + store := NewAgentProfileStore(t.TempDir()) profile, ok, err := store.Get(nodeBranchAgentID("edge-dev")) if err != nil { t.Fatalf("get failed: %v", err) @@ -277,7 +271,7 @@ func TestSubagentProfileStoreIncludesNodeMainBranchProfiles(t *testing.T) { 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 { + if _, err := store.Upsert(AgentProfile{AgentID: profile.AgentID}); err == nil { t.Fatalf("expected node-managed upsert to fail") } if err := store.Delete(profile.AgentID); err == nil { @@ -285,7 +279,7 @@ func TestSubagentProfileStoreIncludesNodeMainBranchProfiles(t *testing.T) { } } -func TestSubagentProfileStoreExcludesLocalNodeMainBranchProfile(t *testing.T) { +func TestAgentProfileStoreExcludesLocalNodeMainBranchProfile(t *testing.T) { runtimecfg.Set(config.DefaultConfig()) t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) @@ -293,12 +287,10 @@ func TestSubagentProfileStoreExcludesLocalNodeMainBranchProfile(t *testing.T) { }) 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", + cfg.Agents.Agents["main"] = config.AgentConfig{ + Enabled: true, + Type: "agent", + PromptFile: "agents/main/AGENT.md", } runtimecfg.Set(cfg) @@ -308,7 +300,7 @@ func TestSubagentProfileStoreExcludesLocalNodeMainBranchProfile(t *testing.T) { Online: true, }) - store := NewSubagentProfileStore(t.TempDir()) + store := NewAgentProfileStore(t.TempDir()) if profile, ok, err := store.Get(nodeBranchAgentID("local")); err != nil { t.Fatalf("get failed: %v", err) } else if ok { diff --git a/pkg/tools/agent_runtime_control_test.go b/pkg/tools/agent_runtime_control_test.go new file mode 100644 index 0000000..a827c90 --- /dev/null +++ b/pkg/tools/agent_runtime_control_test.go @@ -0,0 +1,438 @@ +package tools + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/YspCoder/clawgo/pkg/bus" + "github.com/YspCoder/clawgo/pkg/providers" +) + +func TestAgentSpawnEnforcesTaskQuota(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + manager := NewAgentManager(nil, workspace, nil) + manager.SetRunFunc(func(ctx context.Context, task *AgentTask) (string, error) { + return "ok", nil + }) + store := manager.ProfileStore() + if store == nil { + t.Fatalf("expected profile store") + } + if _, err := store.Upsert(AgentProfile{ + AgentID: "coder", + MaxTaskChars: 8, + }); err != nil { + t.Fatalf("failed to create profile: %v", err) + } + + _, err := manager.Spawn(context.Background(), AgentSpawnOptions{ + Task: "this task is too long", + AgentID: "coder", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, + ExecutionPolicy: &ExecutionPolicy{ + MaxTaskChars: 8, + }, + }) + if err == nil { + t.Fatalf("expected max_task_chars quota to reject spawn") + } +} + +func TestAgentRunWithRetryEventuallySucceeds(t *testing.T) { + workspace := t.TempDir() + manager := NewAgentManager(nil, workspace, nil) + attempts := 0 + manager.SetRunFunc(func(ctx context.Context, task *AgentTask) (string, error) { + attempts++ + if attempts == 1 { + return "", errors.New("temporary failure") + } + return "retry success", nil + }) + + _, err := manager.Spawn(context.Background(), AgentSpawnOptions{ + Task: "retry task", + AgentID: "coder", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, + ExecutionPolicy: &ExecutionPolicy{ + MaxRetries: 1, + RetryBackoff: 1, + }, + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + + task := waitAgentDone(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 TestAgentRunAutoExtendsWhileStillRunning(t *testing.T) { + workspace := t.TempDir() + manager := NewAgentManager(nil, workspace, nil) + manager.SetRunFunc(func(ctx context.Context, task *AgentTask) (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(), AgentSpawnOptions{ + Task: "timeout task", + AgentID: "coder", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, + ExecutionPolicy: &ExecutionPolicy{ + TimeoutSec: 1, + }, + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + + task := waitAgentDone(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 TestAgentBroadcastIncludesFailureStatus(t *testing.T) { + workspace := t.TempDir() + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + manager := NewAgentManager(nil, workspace, msgBus) + manager.SetRunFunc(func(ctx context.Context, task *AgentTask) (string, error) { + return "", errors.New("boom") + }) + + _, err := manager.Spawn(context.Background(), AgentSpawnOptions{ + Task: "failing task", + AgentID: "coder", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + + task := waitAgentDone(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 agent 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 TestAgentManagerRestoresPersistedRuns(t *testing.T) { + workspace := t.TempDir() + manager := NewAgentManager(nil, workspace, nil) + manager.SetRunFunc(func(ctx context.Context, task *AgentTask) (string, error) { + return "persisted", nil + }) + + _, err := manager.Spawn(context.Background(), AgentSpawnOptions{ + Task: "persist task", + AgentID: "coder", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + + task := waitAgentDone(t, manager, 4*time.Second) + if task.Status != "completed" { + t.Fatalf("expected completed task, got %s", task.Status) + } + + reloaded := NewAgentManager(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(), AgentSpawnOptions{ + Task: "second task", + AgentID: "coder", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, + }) + if err != nil { + t.Fatalf("spawn after reload failed: %v", err) + } + tasks := reloaded.ListTasks() + found := false + for _, item := range tasks { + if item.ID == "agent-2" { + found = true + break + } + } + if !found { + t.Fatalf("expected nextID seed to continue from persisted runs, got %+v", tasks) + } + _ = waitAgentDone(t, reloaded, 4*time.Second) + time.Sleep(100 * time.Millisecond) +} + +func TestAgentManagerWorldNPCSuppressesMainNotification(t *testing.T) { + workspace := t.TempDir() + msgBus := bus.NewMessageBus() + manager := NewAgentManager(nil, workspace, msgBus) + manager.SetRunFunc(func(ctx context.Context, task *AgentTask) (string, error) { + return "silent-result", nil + }) + + _, err := manager.Spawn(context.Background(), AgentSpawnOptions{ + Task: "npc world decision", + RunKind: "world_npc", + AgentID: "npc.guard", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + task := waitAgentDone(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 TestAgentManagerRecordsFailuresToEKG(t *testing.T) { + workspace := t.TempDir() + manager := NewAgentManager(nil, workspace, nil) + manager.SetRunFunc(func(ctx context.Context, task *AgentTask) (string, error) { + return "", errors.New("rate limit exceeded") + }) + + _, err := manager.Spawn(context.Background(), AgentSpawnOptions{ + Task: "ekg failure", + AgentID: "coder", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + _ = waitAgentDone(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\":\"agent\"") { + t.Fatalf("expected agent 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 TestAgentManagerAutoRecoversRunningTaskAfterRestart(t *testing.T) { + workspace := t.TempDir() + block := make(chan struct{}) + manager := NewAgentManager(nil, workspace, nil) + manager.SetRunFunc(func(ctx context.Context, task *AgentTask) (string, error) { + <-block + return "should-not-complete-here", nil + }) + + _, err := manager.Spawn(context.Background(), AgentSpawnOptions{ + Task: "recover me", + AgentID: "coder", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + time.Sleep(80 * time.Millisecond) + + recovered := make(chan string, 1) + reloaded := NewAgentManager(nil, workspace, nil) + reloaded.SetRunFunc(func(ctx context.Context, task *AgentTask) (string, error) { + recovered <- task.ID + return "recovered-ok", nil + }) + + select { + case taskID := <-recovered: + if taskID != "agent-1" { + t.Fatalf("expected recovered task id agent-1, got %s", taskID) + } + case <-time.After(2 * time.Second): + t.Fatalf("expected running task to auto-recover after restart") + } + + _ = waitAgentDone(t, reloaded, 4*time.Second) + got, ok := reloaded.GetTask("agent-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) + _ = waitAgentDone(t, manager, 4*time.Second) + time.Sleep(100 * time.Millisecond) +} + +func TestAgentManagerPersistsEvents(t *testing.T) { + workspace := t.TempDir() + manager := NewAgentManager(nil, workspace, nil) + manager.SetRunFunc(func(ctx context.Context, task *AgentTask) (string, error) { + return "ok", nil + }) + + _, err := manager.Spawn(context.Background(), AgentSpawnOptions{ + Task: "event task", + AgentID: "coder", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + + task := waitAgentDone(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") + } + hasCompleted := false + for _, evt := range events { + if evt.Type == "completed" { + hasCompleted = true + break + } + } + if !hasCompleted { + t.Fatalf("expected completed event, got %+v", events) + } +} + +func waitAgentDone(t *testing.T, manager *AgentManager, timeout time.Duration) *AgentTask { + 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 agent 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 TestAgentUsesConfiguredPromptFile(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 := NewAgentManager(provider, workspace, nil) + if _, err := manager.ProfileStore().Upsert(AgentProfile{ + AgentID: "coder", + Status: "active", + PromptFile: "agents/coder/AGENT.md", + }); err != nil { + t.Fatalf("profile upsert failed: %v", err) + } + + _, err := manager.Spawn(context.Background(), AgentSpawnOptions{ + Task: "implement feature", + AgentID: "coder", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + _ = waitAgentDone(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/agent_store.go similarity index 58% rename from pkg/tools/subagent_store.go rename to pkg/tools/agent_store.go index f83dab6..dbc3aa7 100644 --- a/pkg/tools/subagent_store.go +++ b/pkg/tools/agent_store.go @@ -12,7 +12,7 @@ import ( "sync" ) -type SubagentRunEvent struct { +type AgentRunEvent struct { RunID string `json:"run_id"` AgentID string `json:"agent_id,omitempty"` Type string `json:"type"` @@ -22,36 +22,36 @@ type SubagentRunEvent struct { At int64 `json:"ts"` } -type SubagentRunStore struct { +type AgentRunStore struct { dir string runsPath string eventsPath string mu sync.RWMutex - runs map[string]*SubagentTask + runs map[string]*AgentTask } -func NewSubagentRunStore(workspace string) *SubagentRunStore { +func NewAgentRunStore(workspace string) *AgentRunStore { workspace = strings.TrimSpace(workspace) if workspace == "" { return nil } dir := filepath.Join(workspace, "agents", "runtime") - store := &SubagentRunStore{ + store := &AgentRunStore{ dir: dir, - runsPath: filepath.Join(dir, "subagent_runs.jsonl"), - eventsPath: filepath.Join(dir, "subagent_events.jsonl"), - runs: map[string]*SubagentTask{}, + runsPath: filepath.Join(dir, "agent_runs.jsonl"), + eventsPath: filepath.Join(dir, "agent_events.jsonl"), + runs: map[string]*AgentTask{}, } _ = os.MkdirAll(dir, 0755) _ = store.load() return store } -func (s *SubagentRunStore) load() error { +func (s *AgentRunStore) load() error { s.mu.Lock() defer s.mu.Unlock() - s.runs = map[string]*SubagentTask{} + s.runs = map[string]*AgentTask{} f, err := os.Open(s.runsPath) if err != nil { if os.IsNotExist(err) { @@ -71,13 +71,10 @@ func (s *SubagentRunStore) load() error { } var record RunRecord if err := json.Unmarshal([]byte(line), &record); err == nil && strings.TrimSpace(record.ID) != "" { - task := &SubagentTask{ + task := &AgentTask{ ID: record.ID, Task: record.Input, AgentID: record.AgentID, - ThreadID: record.ThreadID, - CorrelationID: record.CorrelationID, - ParentRunID: record.ParentRunID, Status: record.Status, Result: record.Output, Created: record.CreatedAt, @@ -86,21 +83,21 @@ func (s *SubagentRunStore) load() error { s.runs[task.ID] = task continue } - var task SubagentTask + var task AgentTask if err := json.Unmarshal([]byte(line), &task); err != nil { continue } - cp := cloneSubagentTask(&task) + cp := cloneAgentTask(&task) s.runs[task.ID] = cp } return scanner.Err() } -func (s *SubagentRunStore) AppendRun(task *SubagentTask) error { +func (s *AgentRunStore) AppendRun(task *AgentTask) error { if s == nil || task == nil { return nil } - cp := cloneSubagentTask(task) + cp := cloneAgentTask(task) data, err := json.Marshal(taskToRunRecord(cp)) if err != nil { return err @@ -123,7 +120,7 @@ func (s *SubagentRunStore) AppendRun(task *SubagentTask) error { return nil } -func (s *SubagentRunStore) AppendEvent(evt SubagentRunEvent) error { +func (s *AgentRunStore) AppendEvent(evt AgentRunEvent) error { if s == nil { return nil } @@ -156,7 +153,7 @@ func (s *SubagentRunStore) AppendEvent(evt SubagentRunEvent) error { return err } -func (s *SubagentRunStore) Get(runID string) (*SubagentTask, bool) { +func (s *AgentRunStore) Get(runID string) (*AgentTask, bool) { if s == nil { return nil, false } @@ -166,18 +163,18 @@ func (s *SubagentRunStore) Get(runID string) (*SubagentTask, bool) { if !ok { return nil, false } - return cloneSubagentTask(task), true + return cloneAgentTask(task), true } -func (s *SubagentRunStore) List() []*SubagentTask { +func (s *AgentRunStore) List() []*AgentTask { if s == nil { return nil } s.mu.RLock() defer s.mu.RUnlock() - out := make([]*SubagentTask, 0, len(s.runs)) + out := make([]*AgentTask, 0, len(s.runs)) for _, task := range s.runs { - out = append(out, cloneSubagentTask(task)) + out = append(out, cloneAgentTask(task)) } sort.Slice(out, func(i, j int) bool { if out[i].Created != out[j].Created { @@ -188,7 +185,7 @@ func (s *SubagentRunStore) List() []*SubagentTask { return out } -func (s *SubagentRunStore) Events(runID string, limit int) ([]SubagentRunEvent, error) { +func (s *AgentRunStore) Events(runID string, limit int) ([]AgentRunEvent, error) { if s == nil { return nil, nil } @@ -202,7 +199,7 @@ func (s *SubagentRunStore) Events(runID string, limit int) ([]SubagentRunEvent, defer f.Close() runID = strings.TrimSpace(runID) - events := make([]SubagentRunEvent, 0) + events := make([]AgentRunEvent, 0) scanner := bufio.NewScanner(f) buf := make([]byte, 0, 64*1024) scanner.Buffer(buf, 2*1024*1024) @@ -211,13 +208,13 @@ func (s *SubagentRunStore) Events(runID string, limit int) ([]SubagentRunEvent, if line == "" { continue } - var evt SubagentRunEvent + var evt AgentRunEvent if err := json.Unmarshal([]byte(line), &evt); err != nil { var record EventRecord if err := json.Unmarshal([]byte(line), &record); err != nil { continue } - evt = SubagentRunEvent{ + evt = AgentRunEvent{ RunID: record.RunID, AgentID: record.AgentID, Type: record.Type, @@ -242,7 +239,7 @@ func (s *SubagentRunStore) Events(runID string, limit int) ([]SubagentRunEvent, return events, nil } -func (s *SubagentRunStore) NextIDSeed() int { +func (s *AgentRunStore) NextIDSeed() int { if s == nil { return 1 } @@ -250,7 +247,7 @@ func (s *SubagentRunStore) NextIDSeed() int { defer s.mu.RUnlock() maxSeq := 0 for runID := range s.runs { - if n := parseSubagentSequence(runID); n > maxSeq { + if n := parseAgentSequence(runID); n > maxSeq { maxSeq = n } } @@ -260,85 +257,131 @@ func (s *SubagentRunStore) NextIDSeed() int { return maxSeq + 1 } -func parseSubagentSequence(runID string) int { +func parseAgentSequence(runID string) int { runID = strings.TrimSpace(runID) - if !strings.HasPrefix(runID, "subagent-") { + if !strings.HasPrefix(runID, "agent-") { return 0 } - n, _ := strconv.Atoi(strings.TrimPrefix(runID, "subagent-")) + n, _ := strconv.Atoi(strings.TrimPrefix(runID, "agent-")) return n } -func cloneSubagentTask(task *SubagentTask) *SubagentTask { +func cloneAgentTask(task *AgentTask) *AgentTask { if task == nil { return nil } cp := *task - if len(task.ToolAllowlist) > 0 { - cp.ToolAllowlist = append([]string(nil), task.ToolAllowlist...) - } - if len(task.Steering) > 0 { - cp.Steering = append([]string(nil), task.Steering...) - } - if task.SharedState != nil { - cp.SharedState = make(map[string]interface{}, len(task.SharedState)) - for k, v := range task.SharedState { - cp.SharedState[k] = v - } - } + cp.Target = cloneTargetRef(task.Target) + cp.ExecutionPolicy = cloneExecutionPolicy(task.ExecutionPolicy) + cp.WorldDecision = cloneWorldDecisionContext(task.WorldDecision) + cp.Origin = cloneOriginRef(task.Origin) return &cp } -func taskToTaskRecord(task *SubagentTask) TaskRecord { +func taskToTaskRecord(task *AgentTask) TaskRecord { if task == nil { return TaskRecord{} } 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, + ID: task.ID, + OwnerAgentID: task.AgentID, + Status: strings.TrimSpace(task.Status), + Input: task.Task, + Origin: formatTaskOrigin(task.Origin), + CreatedAt: task.Created, + UpdatedAt: task.Updated, } } -func taskRuntimeError(task *SubagentTask) *RuntimeError { +func taskRuntimeError(task *AgentTask) *RuntimeError { if task == nil || !strings.EqualFold(strings.TrimSpace(task.Status), RuntimeStatusFailed) { return nil } msg := strings.TrimSpace(task.Result) msg = strings.TrimPrefix(msg, "Error:") msg = strings.TrimSpace(msg) - return NewRuntimeError("subagent_failed", msg, "subagent", false, "subagent") + return NewRuntimeError("agent_failed", msg, "agent", false, "agent") } -func taskToRunRecord(task *SubagentTask) RunRecord { +func taskToRunRecord(task *AgentTask) RunRecord { if task == nil { return RunRecord{} } return RunRecord{ - ID: task.ID, - TaskID: task.ID, - ThreadID: task.ThreadID, - CorrelationID: task.CorrelationID, - AgentID: task.AgentID, - ParentRunID: task.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, + ID: task.ID, + TaskID: task.ID, + AgentID: task.AgentID, + Kind: agentRunKind(task), + Status: strings.TrimSpace(task.Status), + Input: task.Task, + Output: strings.TrimSpace(task.Result), + Error: taskRuntimeError(task), + CreatedAt: task.Created, + UpdatedAt: task.Updated, } } -func formatSubagentEventLog(evt SubagentRunEvent) string { +func agentRunKind(task *AgentTask) string { + if task == nil { + return "agent" + } + if IsWorldDecisionTask(task) { + return "world_npc" + } + return normalizeAgentRunKind(task.RunKind, task.WorldDecision) +} + +func formatTaskOrigin(origin *OriginRef) string { + channel, chatID := OriginValues(origin) + return channel + ":" + chatID +} + +func cloneMap(in map[string]interface{}) map[string]interface{} { + if in == nil { + return nil + } + out := make(map[string]interface{}, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneMapSlice(in []map[string]interface{}) []map[string]interface{} { + if len(in) == 0 { + return nil + } + out := make([]map[string]interface{}, 0, len(in)) + for _, item := range in { + out = append(out, cloneMap(item)) + } + return out +} + +func cloneWorldDecisionContext(in *WorldDecisionContext) *WorldDecisionContext { + if in == nil { + return nil + } + return &WorldDecisionContext{ + WorldTick: in.WorldTick, + WorldSnapshot: cloneMap(in.WorldSnapshot), + NPCSnapshot: cloneMap(in.NPCSnapshot), + VisibleEvents: cloneMapSlice(in.VisibleEvents), + IntentSchemaVersion: strings.TrimSpace(in.IntentSchemaVersion), + } +} + +func IsWorldDecisionTask(task *AgentTask) bool { + if task == nil { + return false + } + if task.WorldDecision != nil { + return true + } + return strings.EqualFold(strings.TrimSpace(task.RunKind), "world_npc") +} + +func formatAgentEventLog(evt AgentRunEvent) string { base := fmt.Sprintf("- %d %s", evt.At, evt.Type) if strings.TrimSpace(evt.Status) != "" { base += fmt.Sprintf(" status=%s", evt.Status) 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..6c1e89d 100644 --- a/pkg/tools/io_arg_parsing_test.go +++ b/pkg/tools/io_arg_parsing_test.go @@ -4,7 +4,6 @@ import ( "context" "strings" "testing" - "time" "github.com/YspCoder/clawgo/pkg/config" ) @@ -68,29 +67,6 @@ 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) { - return "ok", nil - }) - tool := NewSpawnTool(manager) - - out, err := tool.Execute(context.Background(), map[string]interface{}{ - "task": "implement check", - "agent_id": "coder", - "max_retries": "2", - "retry_backoff_ms": "100", - "timeout_sec": "5", - }) - if err != nil { - t.Fatalf("spawn failed: %v", err) - } - if !strings.Contains(out, "spawned") && !strings.Contains(strings.ToLower(out), "subagent") { - t.Fatalf("unexpected spawn output: %s", out) - } - time.Sleep(50 * time.Millisecond) -} - func TestExecBrowserWebToolsParseStringArgs(t *testing.T) { t.Parallel() diff --git a/pkg/tools/memory.go b/pkg/tools/memory.go index 0084ef9..0157b85 100644 --- a/pkg/tools/memory.go +++ b/pkg/tools/memory.go @@ -56,7 +56,7 @@ func (t *MemorySearchTool) Parameters() map[string]interface{} { }, "namespace": map[string]interface{}{ "type": "string", - "description": "Optional memory namespace. Use main for workspace memory, or subagent id for isolated memory.", + "description": "Optional memory namespace. Use main for workspace memory, or agent id for isolated memory.", "default": "main", }, "maxResults": map[string]interface{}{ diff --git a/pkg/tools/memory_get.go b/pkg/tools/memory_get.go index 32db72b..6379750 100644 --- a/pkg/tools/memory_get.go +++ b/pkg/tools/memory_get.go @@ -35,7 +35,7 @@ func (t *MemoryGetTool) Parameters() map[string]interface{} { }, "namespace": map[string]interface{}{ "type": "string", - "description": "Optional memory namespace. Use main for workspace memory, or subagent id for isolated memory.", + "description": "Optional memory namespace. Use main for workspace memory, or agent id for isolated memory.", "default": "main", }, "from": map[string]interface{}{ diff --git a/pkg/tools/memory_namespace.go b/pkg/tools/memory_namespace.go index af0669f..d6b4895 100644 --- a/pkg/tools/memory_namespace.go +++ b/pkg/tools/memory_namespace.go @@ -6,7 +6,7 @@ import ( ) func normalizeMemoryNamespace(in string) string { - v := normalizeSubagentIdentifier(in) + v := normalizeAgentIdentifier(in) if v == "" { return "main" } diff --git a/pkg/tools/memory_write.go b/pkg/tools/memory_write.go index 6caaf9e..6014c59 100644 --- a/pkg/tools/memory_write.go +++ b/pkg/tools/memory_write.go @@ -35,7 +35,7 @@ func (t *MemoryWriteTool) Parameters() map[string]interface{} { }, "namespace": map[string]interface{}{ "type": "string", - "description": "Optional memory namespace. Use main for workspace memory, or subagent id for isolated memory.", + "description": "Optional memory namespace. Use main for workspace memory, or agent id for isolated memory.", "default": "main", }, "kind": map[string]interface{}{ diff --git a/pkg/tools/nodes_tool.go b/pkg/tools/nodes_tool.go index 4f522d0..226af6b 100644 --- a/pkg/tools/nodes_tool.go +++ b/pkg/tools/nodes_tool.go @@ -122,7 +122,7 @@ func (t *NodesTool) Execute(ctx context.Context, args map[string]interface{}) (s return "", fmt.Errorf("node %s does not support action=%s", nodeID, action) } if t.router == nil { - return "", fmt.Errorf("nodes transport router not configured") + return "", fmt.Errorf("nodes transport not configured") } started := time.Now() resp, err := t.router.Dispatch(ctx, req, mode) diff --git a/pkg/tools/runtime_snapshot_test.go b/pkg/tools/runtime_snapshot_test.go index 508a12c..a0a7d28 100644 --- a/pkg/tools/runtime_snapshot_test.go +++ b/pkg/tools/runtime_snapshot_test.go @@ -6,17 +6,16 @@ import ( "time" ) -func TestSubagentManagerRuntimeSnapshot(t *testing.T) { +func TestAgentManagerRuntimeSnapshot(t *testing.T) { workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + manager := NewAgentManager(nil, workspace, nil) + manager.SetRunFunc(func(ctx context.Context, task *AgentTask) (string, error) { return "snapshot-result", nil }) - task, err := manager.SpawnTask(context.Background(), SubagentSpawnOptions{ + task, err := manager.SpawnTask(context.Background(), AgentSpawnOptions{ Task: "implement snapshot support", AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", + Origin: &OriginRef{Channel: "cli", ChatID: "direct"}, }) if err != nil { t.Fatalf("spawn task failed: %v", err) @@ -29,14 +28,7 @@ func TestSubagentManagerRuntimeSnapshot(t *testing.T) { 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.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.SourceType != "agent_message" { - t.Fatalf("expected agent message artifact source type, got %+v", msgArtifact) - } - if msgArtifact.FromAgent == "" || msgArtifact.ToAgent == "" || msgArtifact.Name == "" { - t.Fatalf("expected runtime snapshot artifact to preserve message metadata, got %+v", msgArtifact) + if len(snapshot.Events) == 0 { + t.Fatalf("expected runtime snapshot to include events: %+v", snapshot) } } diff --git a/pkg/tools/runtime_types.go b/pkg/tools/runtime_types.go index 8a5a060..a0fa8e8 100644 --- a/pkg/tools/runtime_types.go +++ b/pkg/tools/runtime_types.go @@ -37,32 +37,26 @@ func (d DispatchDecision) Valid() bool { } type TaskRecord struct { - ID string `json:"id"` - ThreadID string `json:"thread_id,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` - OwnerAgentID string `json:"owner_agent_id,omitempty"` - Status string `json:"status"` - Input string `json:"input,omitempty"` - OriginChannel string `json:"origin_channel,omitempty"` - OriginChatID string `json:"origin_chat_id,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + ID string `json:"id"` + OwnerAgentID string `json:"owner_agent_id,omitempty"` + Status string `json:"status"` + Input string `json:"input,omitempty"` + Origin string `json:"origin,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } type RunRecord struct { - ID string `json:"id"` - TaskID string `json:"task_id,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` - AgentID string `json:"agent_id,omitempty"` - ParentRunID string `json:"parent_run_id,omitempty"` - Kind string `json:"kind,omitempty"` - Status string `json:"status"` - Input string `json:"input,omitempty"` - Output string `json:"output,omitempty"` - Error *RuntimeError `json:"error,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + ID string `json:"id"` + TaskID string `json:"task_id,omitempty"` + AgentID string `json:"agent_id,omitempty"` + Kind string `json:"kind,omitempty"` + Status string `json:"status"` + Input string `json:"input,omitempty"` + Output string `json:"output,omitempty"` + Error *RuntimeError `json:"error,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } type EventRecord struct { @@ -78,42 +72,11 @@ type EventRecord struct { At int64 `json:"ts"` } -type ArtifactRecord struct { - ID string `json:"id,omitempty"` - RunID string `json:"run_id,omitempty"` - TaskID string `json:"task_id,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - Kind string `json:"kind,omitempty"` - Name string `json:"name,omitempty"` - Content string `json:"content,omitempty"` - AgentID string `json:"agent_id,omitempty"` - FromAgent string `json:"from_agent,omitempty"` - ToAgent string `json:"to_agent,omitempty"` - ReplyTo string `json:"reply_to,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` - Status string `json:"status,omitempty"` - RequiresReply bool `json:"requires_reply,omitempty"` - CreatedAt int64 `json:"created_at"` - Visible bool `json:"visible"` - SourceType string `json:"source_type,omitempty"` -} - -type ThreadRecord struct { - ID string `json:"id"` - OwnerAgentID string `json:"owner_agent_id,omitempty"` - Participants []string `json:"participants,omitempty"` - Status string `json:"status"` - Topic string `json:"topic,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` -} - type RuntimeSnapshot struct { - Tasks []TaskRecord `json:"tasks,omitempty"` - Runs []RunRecord `json:"runs,omitempty"` - Events []EventRecord `json:"events,omitempty"` - Threads []ThreadRecord `json:"threads,omitempty"` - Artifacts []ArtifactRecord `json:"artifacts,omitempty"` + Tasks []TaskRecord `json:"tasks,omitempty"` + Runs []RunRecord `json:"runs,omitempty"` + Events []EventRecord `json:"events,omitempty"` + World interface{} `json:"world,omitempty"` } type ExecutionRun struct { diff --git a/pkg/tools/skill_exec_test.go b/pkg/tools/skill_exec_test.go index 77dae9b..316686c 100644 --- a/pkg/tools/skill_exec_test.go +++ b/pkg/tools/skill_exec_test.go @@ -11,7 +11,7 @@ func TestSkillExecWriteAuditIncludesCallerIdentity(t *testing.T) { workspace := t.TempDir() tool := NewSkillExecTool(workspace) - tool.writeAudit("demo", "scripts/run.sh", "test", "coder", "subagent", true, "") + tool.writeAudit("demo", "scripts/run.sh", "test", "coder", "agent", true, "") data, err := os.ReadFile(filepath.Join(workspace, "memory", "skill-audit.jsonl")) if err != nil { @@ -21,7 +21,7 @@ func TestSkillExecWriteAuditIncludesCallerIdentity(t *testing.T) { if !strings.Contains(text, `"caller_agent":"coder"`) { t.Fatalf("expected caller_agent in audit row, got: %s", text) } - if !strings.Contains(text, `"caller_scope":"subagent"`) { + if !strings.Contains(text, `"caller_scope":"agent"`) { t.Fatalf("expected caller_scope in audit row, got: %s", text) } } diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go deleted file mode 100644 index d3fd356..0000000 --- a/pkg/tools/spawn.go +++ /dev/null @@ -1,149 +0,0 @@ -package tools - -import ( - "context" - "fmt" - "sync" -) - -type SpawnTool struct { - mu sync.RWMutex - manager *SubagentManager - originChannel string - originChatID string -} - -func NewSpawnTool(manager *SubagentManager) *SpawnTool { - return &SpawnTool{ - manager: manager, - originChannel: "cli", - originChatID: "direct", - } -} - -func (t *SpawnTool) Name() string { - return "spawn" -} - -func (t *SpawnTool) Description() string { - return "Spawn a subagent to handle a task in the background. Use this for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done." -} - -func (t *SpawnTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "task": map[string]interface{}{ - "type": "string", - "description": "The task for subagent to complete", - }, - "label": map[string]interface{}{ - "type": "string", - "description": "Optional short label for the task (for display)", - }, - "role": map[string]interface{}{ - "type": "string", - "description": "Optional role for this subagent, e.g. research/coding/testing", - }, - "agent_id": map[string]interface{}{ - "type": "string", - "description": "Optional logical agent ID. If omitted, role will be used as fallback.", - }, - "max_retries": map[string]interface{}{ - "type": "integer", - "description": "Optional retry limit for this task.", - }, - "retry_backoff_ms": map[string]interface{}{ - "type": "integer", - "description": "Optional retry backoff in milliseconds.", - }, - "timeout_sec": map[string]interface{}{ - "type": "integer", - "description": "Optional per-attempt timeout in seconds.", - }, - "max_task_chars": map[string]interface{}{ - "type": "integer", - "description": "Optional task size quota in characters.", - }, - "max_result_chars": map[string]interface{}{ - "type": "integer", - "description": "Optional result size quota in characters.", - }, - "channel": map[string]interface{}{ - "type": "string", - "description": "Optional origin channel override", - }, - "chat_id": map[string]interface{}{ - "type": "string", - "description": "Optional origin chat ID override", - }, - }, - "required": []string{"task"}, - } -} - -func (t *SpawnTool) SetContext(channel, chatID string) { - t.mu.Lock() - defer t.mu.Unlock() - t.originChannel = channel - t.originChatID = chatID -} - -func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { - task := MapStringArg(args, "task") - if task == "" { - return "", fmt.Errorf("task is required") - } - - label := MapStringArg(args, "label") - role := MapStringArg(args, "role") - agentID := MapStringArg(args, "agent_id") - maxRetries := MapIntArg(args, "max_retries", 0) - retryBackoff := MapIntArg(args, "retry_backoff_ms", 0) - timeoutSec := MapIntArg(args, "timeout_sec", 0) - maxTaskChars := MapIntArg(args, "max_task_chars", 0) - maxResultChars := MapIntArg(args, "max_result_chars", 0) - if label == "" && role != "" { - label = role - } else if label == "" && agentID != "" { - label = agentID - } - - if t.manager == nil { - return "Error: Subagent manager not configured", nil - } - - originChannel := MapStringArg(args, "channel") - originChatID := MapStringArg(args, "chat_id") - if originChannel == "" || originChatID == "" { - t.mu.RLock() - defaultChannel := t.originChannel - defaultChatID := t.originChatID - t.mu.RUnlock() - if originChannel == "" { - originChannel = defaultChannel - } - if originChatID == "" { - originChatID = defaultChatID - } - } - - result, err := t.manager.Spawn(ctx, SubagentSpawnOptions{ - Task: task, - Label: label, - Role: role, - AgentID: agentID, - MaxRetries: maxRetries, - RetryBackoff: retryBackoff, - TimeoutSec: timeoutSec, - MaxTaskChars: maxTaskChars, - MaxResultChars: maxResultChars, - OriginChannel: originChannel, - OriginChatID: originChatID, - }) - if err != nil { - return "", fmt.Errorf("failed to spawn subagent: %w", err) - } - - return result, nil -} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go deleted file mode 100644 index c0e99a4..0000000 --- a/pkg/tools/subagent.go +++ /dev/null @@ -1,1270 +0,0 @@ -package tools - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/ekg" - "github.com/YspCoder/clawgo/pkg/providers" -) - -type SubagentTask struct { - ID string `json:"id"` - Task string `json:"task"` - Label string `json:"label"` - Role string `json:"role"` - AgentID string `json:"agent_id"` - Transport string `json:"transport,omitempty"` - NodeID string `json:"node_id,omitempty"` - ParentAgentID string `json:"parent_agent_id,omitempty"` - NotifyMainPolicy string `json:"notify_main_policy,omitempty"` - SessionKey string `json:"session_key"` - MemoryNS string `json:"memory_ns"` - SystemPromptFile string `json:"system_prompt_file,omitempty"` - ToolAllowlist []string `json:"tool_allowlist,omitempty"` - MaxRetries int `json:"max_retries,omitempty"` - RetryBackoff int `json:"retry_backoff,omitempty"` - TimeoutSec int `json:"timeout_sec,omitempty"` - MaxTaskChars int `json:"max_task_chars,omitempty"` - MaxResultChars int `json:"max_result_chars,omitempty"` - RetryCount int `json:"retry_count,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` - ParentRunID string `json:"parent_run_id,omitempty"` - LastMessageID string `json:"last_message_id,omitempty"` - WaitingReply bool `json:"waiting_for_reply,omitempty"` - SharedState map[string]interface{} `json:"shared_state,omitempty"` - OriginChannel string `json:"origin_channel,omitempty"` - OriginChatID string `json:"origin_chat_id,omitempty"` - Status string `json:"status"` - Result string `json:"result,omitempty"` - Steering []string `json:"steering,omitempty"` - Created int64 `json:"created"` - Updated int64 `json:"updated"` -} - -type SubagentManager struct { - tasks map[string]*SubagentTask - cancelFuncs map[string]context.CancelFunc - waiters map[string]map[chan struct{}]struct{} - recoverableTaskIDs []string - archiveAfterMinute int64 - mu sync.RWMutex - provider providers.LLMProvider - bus *bus.MessageBus - workspace string - nextID int - runFunc SubagentRunFunc - profileStore *SubagentProfileStore - runStore *SubagentRunStore - mailboxStore *AgentMailboxStore - ekg *ekg.Engine -} - -type SubagentSpawnOptions struct { - Task string - Label string - Role string - AgentID string - NotifyMainPolicy string - MaxRetries int - RetryBackoff int - TimeoutSec int - MaxTaskChars int - MaxResultChars int - OriginChannel string - OriginChatID string - ThreadID string - CorrelationID string - ParentRunID string -} - -func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *bus.MessageBus) *SubagentManager { - store := NewSubagentProfileStore(workspace) - runStore := NewSubagentRunStore(workspace) - mailboxStore := NewAgentMailboxStore(workspace) - mgr := &SubagentManager{ - tasks: make(map[string]*SubagentTask), - cancelFuncs: make(map[string]context.CancelFunc), - waiters: make(map[string]map[chan struct{}]struct{}), - archiveAfterMinute: 60, - provider: provider, - bus: bus, - workspace: workspace, - nextID: 1, - 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) - } - } - mgr.nextID = runStore.NextIDSeed() - } - go mgr.resumeRecoveredTasks() - return mgr -} - -func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) (string, error) { - task, err := sm.spawnTask(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) - } - if task.Role != "" { - desc += fmt.Sprintf(" role=%s", task.Role) - } - return desc, nil -} - -func (sm *SubagentManager) SpawnTask(ctx context.Context, opts SubagentSpawnOptions) (*SubagentTask, error) { - return sm.spawnTask(ctx, opts) -} - -func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOptions) (*SubagentTask, error) { - task := strings.TrimSpace(opts.Task) - if task == "" { - return nil, fmt.Errorf("task is required") - } - label := strings.TrimSpace(opts.Label) - role := strings.TrimSpace(opts.Role) - agentID := normalizeSubagentIdentifier(opts.AgentID) - originalRole := role - var profile *SubagentProfile - if sm.profileStore != nil { - if agentID != "" { - if p, ok, err := sm.profileStore.Get(agentID); err != nil { - return nil, err - } else if ok { - profile = p - } - } else if role != "" { - if p, ok, err := sm.profileStore.FindByRole(role); err != nil { - return nil, err - } else if ok { - profile = p - agentID = normalizeSubagentIdentifier(p.AgentID) - } - } - } - if agentID == "" { - agentID = normalizeSubagentIdentifier(role) - } - if agentID == "" { - agentID = "default" - } - memoryNS := agentID - systemPromptFile := "" - transport := "local" - nodeID := "" - parentAgentID := "" - notifyMainPolicy := "final_only" - toolAllowlist := []string(nil) - maxRetries := 0 - retryBackoff := 1000 - timeoutSec := 0 - maxTaskChars := 0 - maxResultChars := 0 - if profile == nil && sm.profileStore != nil { - if p, ok, err := sm.profileStore.Get(agentID); err != nil { - return nil, err - } else if ok { - profile = p - } - } - if profile != nil { - if strings.EqualFold(strings.TrimSpace(profile.Status), "disabled") { - return nil, fmt.Errorf("subagent profile '%s' is disabled", profile.AgentID) - } - if label == "" { - label = strings.TrimSpace(profile.Name) - } - if role == "" { - role = strings.TrimSpace(profile.Role) - } - if ns := normalizeSubagentIdentifier(profile.MemoryNamespace); ns != "" { - memoryNS = ns - } - transport = strings.TrimSpace(profile.Transport) - if transport == "" { - transport = "local" - } - nodeID = strings.TrimSpace(profile.NodeID) - parentAgentID = strings.TrimSpace(profile.ParentAgentID) - notifyMainPolicy = normalizeNotifyMainPolicy(profile.NotifyMainPolicy) - systemPromptFile = strings.TrimSpace(profile.SystemPromptFile) - toolAllowlist = append([]string(nil), profile.ToolAllowlist...) - maxRetries = profile.MaxRetries - retryBackoff = profile.RetryBackoff - timeoutSec = profile.TimeoutSec - maxTaskChars = profile.MaxTaskChars - maxResultChars = profile.MaxResultChars - } - if opts.MaxRetries > 0 { - maxRetries = opts.MaxRetries - } - if opts.RetryBackoff > 0 { - retryBackoff = opts.RetryBackoff - } - if opts.TimeoutSec > 0 { - timeoutSec = opts.TimeoutSec - } - if opts.MaxTaskChars > 0 { - maxTaskChars = opts.MaxTaskChars - } - if opts.MaxResultChars > 0 { - maxResultChars = opts.MaxResultChars - } - if maxTaskChars > 0 && len(task) > maxTaskChars { - return nil, fmt.Errorf("task exceeds max_task_chars quota (%d > %d)", len(task), maxTaskChars) - } - maxRetries = normalizePositiveBound(maxRetries, 0, 8) - retryBackoff = normalizePositiveBound(retryBackoff, 500, 120000) - timeoutSec = normalizePositiveBound(timeoutSec, 0, 3600) - maxTaskChars = normalizePositiveBound(maxTaskChars, 0, 400000) - maxResultChars = normalizePositiveBound(maxResultChars, 0, 400000) - if role == "" { - role = originalRole - } - originChannel := strings.TrimSpace(opts.OriginChannel) - originChatID := strings.TrimSpace(opts.OriginChatID) - if raw := strings.TrimSpace(opts.NotifyMainPolicy); raw != "" { - notifyMainPolicy = normalizeNotifyMainPolicy(raw) - } - threadID := strings.TrimSpace(opts.ThreadID) - correlationID := strings.TrimSpace(opts.CorrelationID) - parentRunID := strings.TrimSpace(opts.ParentRunID) - - sm.mu.Lock() - defer sm.mu.Unlock() - - taskID := fmt.Sprintf("subagent-%d", sm.nextID) - sm.nextID++ - sessionKey := buildSubagentSessionKey(agentID, taskID) - - now := time.Now().UnixMilli() - if correlationID == "" { - correlationID = taskID - } - if sm.mailboxStore != nil { - thread, err := sm.mailboxStore.EnsureThread(AgentThread{ - ThreadID: threadID, - Owner: "main", - Participants: []string{"main", agentID}, - Status: "open", - Topic: task, - CreatedAt: now, - UpdatedAt: now, - }) - if err == nil { - threadID = thread.ThreadID - } - } - subagentTask := &SubagentTask{ - ID: taskID, - Task: task, - Label: label, - Role: role, - AgentID: agentID, - Transport: transport, - NodeID: nodeID, - ParentAgentID: parentAgentID, - NotifyMainPolicy: notifyMainPolicy, - SessionKey: sessionKey, - MemoryNS: memoryNS, - SystemPromptFile: systemPromptFile, - ToolAllowlist: toolAllowlist, - MaxRetries: maxRetries, - RetryBackoff: retryBackoff, - TimeoutSec: timeoutSec, - MaxTaskChars: maxTaskChars, - MaxResultChars: maxResultChars, - RetryCount: 0, - ThreadID: threadID, - CorrelationID: correlationID, - ParentRunID: parentRunID, - OriginChannel: originChannel, - OriginChatID: originChatID, - Status: RuntimeStatusRouting, - Created: now, - Updated: now, - } - taskCtx, cancel := context.WithCancel(ctx) - sm.tasks[taskID] = subagentTask - sm.cancelFuncs[taskID] = cancel - sm.recordMailboxMessageLocked(subagentTask, AgentMessage{ - ThreadID: threadID, - FromAgent: "main", - ToAgent: agentID, - CorrelationID: correlationID, - Type: "task", - Content: task, - RequiresReply: true, - Status: "queued", - CreatedAt: now, - }) - sm.persistTaskLocked(subagentTask, "spawned", "") - - go sm.runTask(taskCtx, subagentTask) - return cloneSubagentTask(subagentTask), nil -} - -func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { - defer func() { - sm.mu.Lock() - delete(sm.cancelFuncs, task.ID) - sm.mu.Unlock() - }() - - sm.mu.Lock() - task.Status = RuntimeStatusRunning - task.Created = time.Now().UnixMilli() - task.Updated = task.Created - sm.persistTaskLocked(task, "started", "") - sm.mu.Unlock() - - result, runErr := sm.runWithRetry(ctx, task) - 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, - ToAgent: "main", - ReplyTo: task.LastMessageID, - CorrelationID: task.CorrelationID, - Type: "result", - Content: task.Result, - Status: "delivered", - CreatedAt: task.Updated, - }) - sm.persistTaskLocked(task, "failed", task.Result) - } 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, - ToAgent: "main", - ReplyTo: task.LastMessageID, - CorrelationID: task.CorrelationID, - Type: "result", - Content: task.Result, - Status: "delivered", - CreatedAt: task.Updated, - }) - sm.persistTaskLocked(task, "completed", task.Result) - } - sm.mu.Unlock() - - sm.recordEKG(task, runErr) - - // 2. Result broadcast - if sm.bus != nil && shouldNotifyMainOnFinal(task.NotifyMainPolicy, runErr, task) { - announceContent, notifyReason := buildSubagentMainNotification(task, 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, - 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, - "notify_reason": notifyReason, - }, - }) - } - sm.mu.Lock() - sm.notifyTaskWaitersLocked(task.ID) - sm.mu.Unlock() -} - -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": - return "final_only" - case "milestone", "on_blocked", "always", "internal_only": - return strings.ToLower(strings.TrimSpace(v)) - default: - return "final_only" - } -} - -func shouldNotifyMainOnFinal(policy string, runErr error, task *SubagentTask) bool { - switch normalizeNotifyMainPolicy(policy) { - case "internal_only": - return false - case "always", "final_only": - return true - case "on_blocked": - return isBlockedSubagentError(runErr) - case "milestone": - return false - default: - return true - } -} - -func buildSubagentMainNotification(task *SubagentTask, runErr error) (string, string) { - status := "completed" - reason := "final" - if runErr != nil { - status = "failed" - if isBlockedSubagentError(runErr) { - status = "blocked" - reason = "blocked" - } - } - 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), - status, - reason, - summarizeSubagentText(firstNonEmpty(task.Label, task.Task), 120), - summarizeSubagentText(task.Result, 280), - ), reason -} - -func isBlockedSubagentError(err error) bool { - if err == nil { - return false - } - if errors.Is(err, context.DeadlineExceeded) { - return true - } - msg := strings.ToLower(strings.TrimSpace(err.Error())) - if msg == "" { - return false - } - blockedHints := []string{ - "timeout", - "deadline exceeded", - "quota", - "rate limit", - "too many requests", - "permission denied", - "requires input", - "waiting for reply", - "blocked", - } - for _, hint := range blockedHints { - if strings.Contains(msg, hint) { - return true - } - } - return false -} - -func summarizeSubagentText(s string, max int) string { - s = strings.TrimSpace(strings.ReplaceAll(s, "\r\n", "\n")) - s = strings.ReplaceAll(s, "\n", " ") - s = strings.Join(strings.Fields(s), " ") - if s == "" { - return "(empty)" - } - if max > 0 && len(s) > max { - return strings.TrimSpace(s[:max-3]) + "..." - } - return s -} - -func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return strings.TrimSpace(v) - } - } - 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) - - 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) - }, - ) - if err == nil { - sm.mu.Lock() - task.RetryCount = attempt - task.Updated = time.Now().UnixMilli() - sm.persistTaskLocked(task, "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()) - sm.mu.Unlock() - if attempt >= maxRetries { - break - } - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-time.After(time.Duration(backoffMs) * time.Millisecond): - } - } - if lastErr == nil { - lastErr = fmt.Errorf("subagent task failed with unknown error") - } - return "", lastErr -} - -func (sm *SubagentManager) taskWatchdogProgress(task *SubagentTask) int { - if sm == nil || task == nil { - return 0 - } - 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) - if sm.runFunc != nil { - result, err := sm.runFunc(ctx, task) - if err != nil { - sm.restoreMessageStatuses(consumedIDs) - } else { - sm.ackMessageStatuses(consumedIDs) - } - return result, err - } - if sm.provider == nil { - sm.restoreMessageStatuses(consumedIDs) - return "", fmt.Errorf("no llm provider configured for subagent execution") - } - - systemPrompt := sm.resolveSystemPrompt(task) - messages := []providers.Message{ - { - Role: "system", - Content: systemPrompt, - }, - { - Role: "user", - Content: task.Task, - }, - } - if strings.TrimSpace(pending) != "" { - messages = append(messages, providers.Message{ - Role: "user", - Content: "Mailbox updates on this thread:\n" + pending, - }) - } - - response, err := sm.provider.Chat(ctx, messages, nil, sm.provider.GetDefaultModel(), map[string]interface{}{ - "max_tokens": 4096, - }) - if err != nil { - sm.restoreMessageStatuses(consumedIDs) - return "", err - } - sm.ackMessageStatuses(consumedIDs) - return response.Content, nil -} - -func (sm *SubagentManager) resolveSystemPrompt(task *SubagentTask) 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 { - return systemPrompt - } - if promptFile := strings.TrimSpace(task.SystemPromptFile); promptFile != "" { - if promptText := sm.readWorkspacePromptFile(promptFile); promptText != "" { - return systemPrompt + "\n\nSubagent policy (" + promptFile + "):\n" + promptText - } - } - return systemPrompt -} - -func (sm *SubagentManager) readWorkspacePromptFile(relPath string) string { - ws := strings.TrimSpace(sm.workspace) - relPath = strings.TrimSpace(relPath) - if ws == "" || relPath == "" || filepath.IsAbs(relPath) { - return "" - } - fullPath := filepath.Clean(filepath.Join(ws, relPath)) - relToWorkspace, err := filepath.Rel(ws, fullPath) - if err != nil || strings.HasPrefix(relToWorkspace, "..") { - return "" - } - data, err := os.ReadFile(fullPath) - if err != nil { - return "" - } - return strings.TrimSpace(string(data)) -} - -type SubagentRunFunc func(ctx context.Context, task *SubagentTask) (string, error) - -func (sm *SubagentManager) SetRunFunc(f SubagentRunFunc) { - sm.mu.Lock() - defer sm.mu.Unlock() - sm.runFunc = f - go sm.resumeRecoveredTasks() -} - -func (sm *SubagentManager) ProfileStore() *SubagentProfileStore { - sm.mu.RLock() - defer sm.mu.RUnlock() - return sm.profileStore -} - -func (sm *SubagentManager) resumeRecoveredTasks() { - if sm == nil { - return - } - sm.mu.Lock() - if sm.runFunc == nil && sm.provider == nil { - 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" { - continue - } - task.Updated = time.Now().UnixMilli() - sm.persistTaskLocked(task, "recovered", "auto-resumed after restart") - toResume = append(toResume, task) - } - sm.mu.Unlock() - - for _, task := range toResume { - taskCtx, cancel := context.WithCancel(context.Background()) - sm.mu.Lock() - sm.cancelFuncs[task.ID] = cancel - sm.mu.Unlock() - go sm.runTask(taskCtx, task) - } -} - -func (sm *SubagentManager) NextTaskSequence() 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 { - 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{}{} - } - if sm.runStore != nil { - for _, task := range sm.runStore.List() { - if _, ok := seen[task.ID]; ok { - continue - } - tasks = append(tasks, task) - } - } - return tasks -} - -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) { - if sm.runStore == nil { - return nil, nil - } - return sm.runStore.Events(taskID, limit) -} - -func (sm *SubagentManager) RuntimeSnapshot(limit int) RuntimeSnapshot { - if sm == nil { - return RuntimeSnapshot{} - } - tasks := sm.ListTasks() - snapshot := RuntimeSnapshot{ - Tasks: make([]TaskRecord, 0, len(tasks)), - Runs: make([]RunRecord, 0, len(tasks)), - } - 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 _, evt := range evts { - snapshot.Events = append(snapshot.Events, EventRecord{ - ID: EventRecordID(evt.RunID, evt.Type, evt.At), - RunID: evt.RunID, - TaskID: evt.RunID, - AgentID: evt.AgentID, - Type: evt.Type, - Status: evt.Status, - Message: evt.Message, - RetryCount: evt.RetryCount, - At: evt.At, - }) - } - } - threadID := strings.TrimSpace(task.ThreadID) - if threadID == "" { - continue - } - if _, ok := seenThreads[threadID]; !ok { - if thread, found := sm.Thread(threadID); found { - snapshot.Threads = append(snapshot.Threads, threadToThreadRecord(thread)) - } - seenThreads[threadID] = struct{}{} - } - if msgs, err := sm.ThreadMessages(threadID, limit); err == nil { - for _, msg := range msgs { - snapshot.Artifacts = append(snapshot.Artifacts, messageToArtifactRecord(msg)) - } - } - } - return snapshot -} - -func (sm *SubagentManager) Thread(threadID string) (*AgentThread, bool) { - if sm.mailboxStore == nil { - return nil, false - } - return sm.mailboxStore.Thread(threadID) -} - -func (sm *SubagentManager) ThreadMessages(threadID string, limit int) ([]AgentMessage, error) { - if sm.mailboxStore == nil { - return nil, nil - } - return sm.mailboxStore.MessagesByThread(threadID, limit) -} - -func (sm *SubagentManager) Inbox(agentID string, limit int) ([]AgentMessage, error) { - if sm.mailboxStore == nil { - return nil, nil - } - 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 - } - return sm.mailboxStore.Message(messageID) -} - -func (sm *SubagentManager) pruneArchivedLocked() { - if sm.archiveAfterMinute <= 0 { - return - } - cutoff := time.Now().Add(-time.Duration(sm.archiveAfterMinute) * time.Minute).UnixMilli() - for id, t := range sm.tasks { - if !IsTerminalRuntimeStatus(t.Status) { - continue - } - if t.Updated > 0 && t.Updated < cutoff { - delete(sm.tasks, id) - delete(sm.cancelFuncs, id) - } - } -} - -func normalizePositiveBound(v, min, max int) int { - if v < min { - return min - } - if max > 0 && v > max { - return max - } - return v -} - -func applySubagentResultQuota(result string, maxChars int) string { - if maxChars <= 0 { - return result - } - if len(result) <= maxChars { - return result - } - suffix := "\n\n[TRUNCATED: result exceeds max_result_chars quota]" - trimmed := result[:maxChars] - if len(trimmed)+len(suffix) > maxChars && maxChars > len(suffix) { - trimmed = trimmed[:maxChars-len(suffix)] - } - return strings.TrimSpace(trimmed) + suffix -} - -func normalizeSubagentIdentifier(in string) string { - in = strings.TrimSpace(strings.ToLower(in)) - if in == "" { - return "" - } - var sb strings.Builder - for _, r := range in { - switch { - case r >= 'a' && r <= 'z': - sb.WriteRune(r) - case r >= '0' && r <= '9': - sb.WriteRune(r) - case r == '-' || r == '_' || r == '.': - sb.WriteRune(r) - case r == ' ': - sb.WriteRune('-') - } - } - out := strings.Trim(sb.String(), "-_.") - if out == "" { - return "" - } - return out -} - -func buildSubagentSessionKey(agentID, taskID string) string { - a := normalizeSubagentIdentifier(agentID) - if a == "" { - a = "default" - } - t := normalizeSubagentIdentifier(taskID) - if t == "" { - t = "task" - } - return fmt.Sprintf("subagent:%s:%s", a, t) -} - -func (sm *SubagentManager) persistTaskLocked(task *SubagentTask, eventType, message string) { - if task == nil || sm.runStore == nil { - return - } - cp := cloneSubagentTask(task) - _ = sm.runStore.AppendRun(cp) - _ = sm.runStore.AppendEvent(SubagentRunEvent{ - RunID: cp.ID, - AgentID: cp.AgentID, - Type: strings.TrimSpace(eventType), - Status: cp.Status, - Message: strings.TrimSpace(message), - RetryCount: cp.RetryCount, - At: cp.Updated, - }) -} - -func (sm *SubagentManager) WaitTask(ctx context.Context, taskID string) (*SubagentTask, 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") - } - if ctx == nil { - ctx = context.Background() - } - ch := make(chan struct{}, 1) - sm.mu.Lock() - sm.pruneArchivedLocked() - task, ok := sm.tasks[taskID] - if !ok && sm.runStore != nil { - if persisted, found := sm.runStore.Get(taskID); found && persisted != nil { - if IsTerminalRuntimeStatus(persisted.Status) { - sm.mu.Unlock() - return persisted, true, nil - } - } - } - if ok && task != nil && IsTerminalRuntimeStatus(task.Status) { - cp := cloneSubagentTask(task) - sm.mu.Unlock() - return cp, true, nil - } - waiters := sm.waiters[taskID] - if waiters == nil { - waiters = map[chan struct{}]struct{}{} - sm.waiters[taskID] = waiters - } - waiters[ch] = struct{}{} - sm.mu.Unlock() - - defer sm.removeTaskWaiter(taskID, ch) - for { - select { - case <-ctx.Done(): - return nil, false, ctx.Err() - case <-ch: - sm.mu.Lock() - sm.pruneArchivedLocked() - task, ok := sm.tasks[taskID] - if ok && task != nil && IsTerminalRuntimeStatus(task.Status) { - cp := cloneSubagentTask(task) - 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) { - sm.mu.Unlock() - return persisted, true, nil - } - } - sm.mu.Unlock() - } - } -} - -func (sm *SubagentManager) removeTaskWaiter(taskID string, ch chan struct{}) { - sm.mu.Lock() - defer sm.mu.Unlock() - waiters := sm.waiters[taskID] - if len(waiters) == 0 { - delete(sm.waiters, taskID) - return - } - delete(waiters, ch) - if len(waiters) == 0 { - delete(sm.waiters, taskID) - } -} - -func (sm *SubagentManager) notifyTaskWaitersLocked(taskID string) { - waiters := sm.waiters[taskID] - if len(waiters) == 0 { - delete(sm.waiters, taskID) - return - } - for ch := range waiters { - select { - case ch <- struct{}{}: - default: - } - } - delete(sm.waiters, taskID) -} - -func (sm *SubagentManager) recordMailboxMessageLocked(task *SubagentTask, msg AgentMessage) { - if sm.mailboxStore == nil || task == nil { - return - } - if strings.TrimSpace(msg.ThreadID) == "" { - msg.ThreadID = task.ThreadID - } - stored, err := sm.mailboxStore.AppendMessage(msg) - if err != nil { - return - } - task.LastMessageID = stored.MessageID - if stored.RequiresReply { - task.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 { - return "", nil - } - msgs, err := sm.mailboxStore.ThreadInbox(task.ThreadID, task.AgentID, 0) - if err != nil || len(msgs) == 0 { - return "", nil - } - var sb strings.Builder - consumed := make([]string, 0, len(msgs)) - now := time.Now().UnixMilli() - for _, msg := range msgs { - if _, err := sm.mailboxStore.UpdateMessageStatus(msg.MessageID, "processing", now); err != nil { - continue - } - consumed = append(consumed, msg.MessageID) - sb.WriteString(fmt.Sprintf("- [%s] from=%s type=%s", msg.MessageID, msg.FromAgent, msg.Type)) - if strings.TrimSpace(msg.ReplyTo) != "" { - sb.WriteString(fmt.Sprintf(" reply_to=%s", msg.ReplyTo)) - } - sb.WriteString("\n") - sb.WriteString(strings.TrimSpace(msg.Content)) - sb.WriteString("\n") - } - return strings.TrimSpace(sb.String()), consumed -} - -func (sm *SubagentManager) restoreMessageStatuses(messageIDs []string) { - if sm.mailboxStore == nil || len(messageIDs) == 0 { - return - } - now := time.Now().UnixMilli() - for _, messageID := range messageIDs { - _, _ = sm.mailboxStore.UpdateMessageStatus(messageID, "queued", now) - } -} - -func (sm *SubagentManager) ackMessageStatuses(messageIDs []string) { - if sm.mailboxStore == nil || len(messageIDs) == 0 { - return - } - now := time.Now().UnixMilli() - for _, messageID := range messageIDs { - _, _ = sm.mailboxStore.UpdateMessageStatus(messageID, "acked", now) - } -} 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 77c37c0..0000000 --- a/pkg/tools/subagent_config_tool.go +++ /dev/null @@ -1,114 +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 - action := stringArgFromMap(args, "action") - handlers := map[string]func() (string, error){ - "upsert": func() (string, error) { - result, err := UpsertConfigSubagent(t.getConfigPath(), cloneSubagentConfigArgs(args)) - if err != nil { - return "", err - } - return marshalSubagentConfigPayload(result) - }, - } - if handler := handlers[action]; handler != nil { - return handler() - } - return "", fmt.Errorf("%w: %s", ErrUnsupportedAction, 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_router.go b/pkg/tools/subagent_router.go deleted file mode 100644 index c00bd10..0000000 --- a/pkg/tools/subagent_router.go +++ /dev/null @@ -1,129 +0,0 @@ -package tools - -import ( - "context" - "fmt" - "strings" - "time" -) - -type RouterDispatchRequest struct { - Task string - Label string - Role string - AgentID string - Decision *DispatchDecision - NotifyMainPolicy string - ThreadID string - CorrelationID string - ParentRunID string - OriginChannel string - OriginChatID string - MaxRetries int - RetryBackoff int - TimeoutSec int - MaxTaskChars int - MaxResultChars int -} - -type RouterReply struct { - TaskID string - ThreadID string - CorrelationID string - AgentID string - Status string - Result string - Run RunRecord - Error *RuntimeError -} - -type SubagentRouter struct { - manager *SubagentManager -} - -func NewSubagentRouter(manager *SubagentManager) *SubagentRouter { - return &SubagentRouter{manager: manager} -} - -func (r *SubagentRouter) DispatchTask(ctx context.Context, req RouterDispatchRequest) (*SubagentTask, error) { - if r == nil || r.manager == nil { - return nil, fmt.Errorf("subagent router is not configured") - } - if req.Decision != nil { - if strings.TrimSpace(req.AgentID) == "" { - req.AgentID = strings.TrimSpace(req.Decision.TargetAgent) - } - if strings.TrimSpace(req.Task) == "" { - req.Task = strings.TrimSpace(req.Decision.TaskText) - } - } - task, err := r.manager.SpawnTask(ctx, SubagentSpawnOptions{ - Task: req.Task, - Label: req.Label, - Role: req.Role, - AgentID: req.AgentID, - NotifyMainPolicy: req.NotifyMainPolicy, - ThreadID: req.ThreadID, - CorrelationID: req.CorrelationID, - ParentRunID: req.ParentRunID, - OriginChannel: req.OriginChannel, - OriginChatID: req.OriginChatID, - MaxRetries: req.MaxRetries, - RetryBackoff: req.RetryBackoff, - TimeoutSec: req.TimeoutSec, - MaxTaskChars: req.MaxTaskChars, - MaxResultChars: req.MaxResultChars, - }) - if err != nil { - return nil, err - } - return task, nil -} - -func (r *SubagentRouter) WaitReply(ctx context.Context, taskID 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") - } - task, ok, err := r.manager.WaitTask(ctx, taskID) - if err != nil { - return nil, err - } - if !ok || task == 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), - }, nil -} - -func (r *SubagentRouter) MergeResults(replies []*RouterReply) string { - if len(replies) == 0 { - return "" - } - var sb strings.Builder - for _, reply := range replies { - if reply == nil { - continue - } - sb.WriteString(fmt.Sprintf("[%s] agent=%s status=%s\n", reply.TaskID, reply.AgentID, reply.Status)) - if txt := strings.TrimSpace(reply.Result); txt != "" { - sb.WriteString(txt) - } else { - sb.WriteString("(empty result)") - } - sb.WriteString("\n\n") - } - return strings.TrimSpace(sb.String()) -} diff --git a/pkg/tools/subagent_router_test.go b/pkg/tools/subagent_router_test.go deleted file mode 100644 index cde693e..0000000 --- a/pkg/tools/subagent_router_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package tools - -import ( - "context" - "strings" - "testing" - "time" -) - -func TestSubagentRouterDispatchAndWaitReply(t *testing.T) { - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - return "router-result", nil - }) - router := NewSubagentRouter(manager) - - task, err := router.DispatchTask(context.Background(), RouterDispatchRequest{ - Task: "implement feature", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("dispatch failed: %v", err) - } - if task.ThreadID == "" { - t.Fatalf("expected thread id on dispatched task") - } - - reply, err := router.WaitReply(context.Background(), task.ID, 20*time.Millisecond) - if err != nil { - t.Fatalf("wait reply failed: %v", err) - } - if reply.Status != "completed" || reply.Result != "router-result" { - t.Fatalf("unexpected reply: %+v", reply) - } -} - -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"}, - }) - if !strings.Contains(out, "subagent-1") || !strings.Contains(out, "agent=tester") { - t.Fatalf("unexpected merged output: %s", out) - } -} - -func TestSubagentRouterWaitReplyContextCancel(t *testing.T) { - workspace := t.TempDir() - manager := NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { - <-ctx.Done() - return "", ctx.Err() - }) - router := NewSubagentRouter(manager) - - task, err := router.DispatchTask(context.Background(), RouterDispatchRequest{ - Task: "long task", - AgentID: "coder", - OriginChannel: "cli", - OriginChatID: "direct", - }) - if err != nil { - t.Fatalf("dispatch failed: %v", err) - } - - waitCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - if _, err := router.WaitReply(waitCtx, task.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/subagents_tool.go b/pkg/tools/subagents_tool.go deleted file mode 100644 index 6c41416..0000000 --- a/pkg/tools/subagents_tool.go +++ /dev/null @@ -1,341 +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) - type subagentActionHandler func() (string, error) - var threadHandler subagentActionHandler - handlers := map[string]subagentActionHandler{ - "list": func() (string, error) { - 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 - }, - "info": func() (string, error) { - 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 - }, - "kill": func() (string, error) { - 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 - }, - "steer": func() (string, error) { - 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 - }, - "send": func() (string, error) { - 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 - }, - "reply": func() (string, error) { - 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 - }, - "ack": func() (string, error) { - 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 - }, - "thread": func() (string, error) { - 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 - }, - "inbox": func() (string, error) { - 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 - }, - "log": func() (string, error) { - 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 - }, - "resume": func() (string, error) { - 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 - }, - } - threadHandler = handlers["thread"] - handlers["trace"] = func() (string, error) { return threadHandler() } - if handler := handlers[action]; handler != nil { - return handler() - } - 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/tool_allowlist_groups.go b/pkg/tools/tool_allowlist_groups.go index deee8e7..8348df7 100644 --- a/pkg/tools/tool_allowlist_groups.go +++ b/pkg/tools/tool_allowlist_groups.go @@ -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: "agents", + Description: "World/NPC and agent profile tools", + Aliases: []string{"agent", "agent_runtime"}, + Tools: []string{"agent_profile", "world"}, }, { Name: "skills", diff --git a/pkg/tools/tool_allowlist_groups_test.go b/pkg/tools/tool_allowlist_groups_test.go index 02dabcb..bf839a3 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", "@agents", "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["world"] || !contains["agent_profile"] { + t.Fatalf("agents alias expansion missing world tools: %v", got) } if !contains["skill_exec"] { t.Fatalf("skills alias expansion missing skill_exec: %v", got) diff --git a/pkg/tools/world_tool.go b/pkg/tools/world_tool.go new file mode 100644 index 0000000..73570a9 --- /dev/null +++ b/pkg/tools/world_tool.go @@ -0,0 +1,195 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +type WorldToolRuntime interface { + Snapshot(limit int) (interface{}, error) + Tick(ctx context.Context, source string) (string, error) + NPCList() ([]map[string]interface{}, error) + NPCGet(id string) (map[string]interface{}, bool, error) + EntityList() ([]map[string]interface{}, error) + EntityGet(id string) (map[string]interface{}, bool, error) + WorldGet() (map[string]interface{}, error) + EventLog(limit int) ([]map[string]interface{}, error) + CreateNPC(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) + CreateEntity(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) + QuestList() ([]map[string]interface{}, error) + QuestGet(id string) (map[string]interface{}, bool, error) + CreateQuest(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) +} + +type WorldTool struct { + runtime WorldToolRuntime +} + +func NewWorldTool(runtime WorldToolRuntime) *WorldTool { + return &WorldTool{runtime: runtime} +} + +func (t *WorldTool) Name() string { return "world" } + +func (t *WorldTool) Description() string { + return "Inspect and drive the world runtime: snapshot, tick, npc_list, npc_get, entity_list, entity_get, world_get, event_log, npc_create, entity_create, quest_list, quest_get, quest_create." +} + +func (t *WorldTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{"type": "string", "description": "snapshot|tick|npc_list|npc_get|entity_list|entity_get|world_get|event_log|npc_create|entity_create|quest_list|quest_get|quest_create"}, + "id": map[string]interface{}{"type": "string", "description": "npc id for npc_get"}, + "limit": map[string]interface{}{"type": "integer", "description": "maximum event/snapshot items"}, + "source": map[string]interface{}{"type": "string", "description": "tick source label"}, + "npc_id": map[string]interface{}{"type": "string", "description": "new npc id for npc_create"}, + "name": map[string]interface{}{"type": "string", "description": "display name for npc_create"}, + "persona": map[string]interface{}{"type": "string", "description": "persona for npc_create"}, + "home_location": map[string]interface{}{"type": "string", "description": "home location for npc_create"}, + "default_goals": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "default goals for npc_create", + }, + "title": map[string]interface{}{"type": "string", "description": "quest title"}, + "summary": map[string]interface{}{"type": "string", "description": "quest summary"}, + "owner_npc_id": map[string]interface{}{"type": "string", "description": "quest owner npc id"}, + "participants": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "quest participants", + }, + "entity_id": map[string]interface{}{"type": "string", "description": "entity id"}, + "entity_type": map[string]interface{}{"type": "string", "description": "entity type"}, + "location_id": map[string]interface{}{"type": "string", "description": "entity location"}, + }, + "required": []string{"action"}, + } +} + +func (t *WorldTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { + if t == nil || t.runtime == nil { + return "world runtime not available", nil + } + action := strings.ToLower(MapStringArg(args, "action")) + switch action { + case "snapshot": + out, err := t.runtime.Snapshot(MapIntArg(args, "limit", 12)) + if err != nil { + return "", err + } + return marshalWorldToolOutput(out) + case "tick": + source := strings.TrimSpace(MapStringArg(args, "source")) + if source == "" { + source = "tool" + } + return t.runtime.Tick(ctx, source) + case "npc_list": + out, err := t.runtime.NPCList() + if err != nil { + return "", err + } + return marshalWorldToolOutput(out) + case "npc_get": + id := strings.TrimSpace(MapStringArg(args, "id")) + if id == "" { + id = strings.TrimSpace(MapStringArg(args, "npc_id")) + } + if id == "" { + return "id is required", nil + } + out, found, err := t.runtime.NPCGet(id) + if err != nil { + return "", err + } + if !found { + return "npc not found", nil + } + return marshalWorldToolOutput(out) + case "entity_list": + out, err := t.runtime.EntityList() + if err != nil { + return "", err + } + return marshalWorldToolOutput(out) + case "entity_get": + id := strings.TrimSpace(MapStringArg(args, "id")) + if id == "" { + id = strings.TrimSpace(MapStringArg(args, "entity_id")) + } + if id == "" { + return "id is required", nil + } + out, found, err := t.runtime.EntityGet(id) + if err != nil { + return "", err + } + if !found { + return "entity not found", nil + } + return marshalWorldToolOutput(out) + case "world_get": + out, err := t.runtime.WorldGet() + if err != nil { + return "", err + } + return marshalWorldToolOutput(out) + case "event_log": + out, err := t.runtime.EventLog(MapIntArg(args, "limit", 12)) + if err != nil { + return "", err + } + return marshalWorldToolOutput(out) + case "npc_create": + out, err := t.runtime.CreateNPC(ctx, args) + if err != nil { + return "", err + } + return fmt.Sprintf("Created NPC %s", strings.TrimSpace(MapStringArg(out, "npc_id"))), nil + case "entity_create": + out, err := t.runtime.CreateEntity(ctx, args) + if err != nil { + return "", err + } + return fmt.Sprintf("Created entity %s", strings.TrimSpace(MapStringArg(out, "entity_id"))), nil + case "quest_list": + out, err := t.runtime.QuestList() + if err != nil { + return "", err + } + return marshalWorldToolOutput(out) + case "quest_get": + id := strings.TrimSpace(MapStringArg(args, "id")) + if id == "" { + return "id is required", nil + } + out, found, err := t.runtime.QuestGet(id) + if err != nil { + return "", err + } + if !found { + return "quest not found", nil + } + return marshalWorldToolOutput(out) + case "quest_create": + out, err := t.runtime.CreateQuest(ctx, args) + if err != nil { + return "", err + } + return fmt.Sprintf("Created quest %s", strings.TrimSpace(MapStringArg(out, "quest_id"))), nil + default: + return "unsupported action", nil + } +} + +func marshalWorldToolOutput(v interface{}) (string, error) { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/pkg/world/engine.go b/pkg/world/engine.go new file mode 100644 index 0000000..391316a --- /dev/null +++ b/pkg/world/engine.go @@ -0,0 +1,465 @@ +package world + +import ( + "fmt" + "sort" + "strings" + "time" +) + +type Engine struct{} + +func NewEngine() *Engine { + return &Engine{} +} + +func (e *Engine) EnsureWorld(state *WorldState) { + if state == nil { + return + } + if strings.TrimSpace(state.WorldID) == "" { + *state = DefaultWorldState() + return + } + if state.Clock.TickDuration <= 0 { + state.Clock.TickDuration = 30 + } + if state.Locations == nil || len(state.Locations) == 0 { + state.Locations = DefaultWorldState().Locations + } + if state.GlobalFacts == nil { + state.GlobalFacts = map[string]interface{}{} + } + if state.Entities == nil { + state.Entities = map[string]Entity{} + } + if state.ActiveQuests == nil { + state.ActiveQuests = map[string]QuestState{} + } +} + +func (e *Engine) EnsureNPCState(blueprint NPCBlueprint, state NPCState) NPCState { + if strings.TrimSpace(state.NPCID) == "" { + state.NPCID = strings.TrimSpace(blueprint.NPCID) + } + if strings.TrimSpace(state.ProfileRef) == "" { + state.ProfileRef = strings.TrimSpace(blueprint.NPCID) + } + if strings.TrimSpace(state.CurrentLocation) == "" { + state.CurrentLocation = firstNonEmpty(blueprint.HomeLocation, "commons") + } + if len(state.Goals.LongTerm) == 0 && len(blueprint.DefaultGoals) > 0 { + state.Goals.LongTerm = append([]string(nil), blueprint.DefaultGoals...) + } + if state.Beliefs == nil { + state.Beliefs = map[string]string{} + } + if state.Relationships == nil { + state.Relationships = map[string]string{} + } + if state.Inventory == nil { + state.Inventory = map[string]int{} + } + if state.Assets == nil { + state.Assets = map[string]interface{}{} + } + if strings.TrimSpace(state.Status) == "" { + state.Status = "active" + } + return state +} + +func (e *Engine) NextTick(state *WorldState) int64 { + e.EnsureWorld(state) + state.Clock.Tick++ + now := time.Now().Unix() + if state.Clock.SimTimeUnix <= 0 { + state.Clock.SimTimeUnix = now + } + state.Clock.SimTimeUnix += maxInt64(state.Clock.TickDuration, 1) + state.Clock.LastAdvance = now + return state.Clock.Tick +} + +func (e *Engine) BuildUserEvent(state *WorldState, input, locationID string) WorldEvent { + e.EnsureWorld(state) + locationID = firstNonEmpty(locationID, "commons") + return WorldEvent{ + ID: fmt.Sprintf("evt-user-%d", time.Now().UnixNano()), + Type: "user_input", + Source: "user", + ActorID: "user", + LocationID: locationID, + Content: strings.TrimSpace(input), + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } +} + +func (e *Engine) VisibleEventsForNPC(state WorldState, npc NPCState, events []WorldEvent, scope int) []WorldEvent { + out := make([]WorldEvent, 0, len(events)) + location := strings.TrimSpace(npc.CurrentLocation) + for _, evt := range events { + if evt.LocationID == "" || evt.ActorID == npc.NPCID || evt.Source == "world" { + out = append(out, evt) + continue + } + if locationVisibleWithinScope(state, location, strings.TrimSpace(evt.LocationID), scope) { + out = append(out, evt) + continue + } + if scope > 0 && len(evt.VisibleTo) > 0 { + for _, item := range evt.VisibleTo { + if strings.TrimSpace(item) == npc.NPCID { + out = append(out, evt) + break + } + } + } + } + return out +} + +func (e *Engine) ApplyIntent(state *WorldState, npc *NPCState, intent ActionIntent) WorldDelta { + e.EnsureWorld(state) + delta := WorldDelta{Applied: false, Intent: intent} + if npc == nil { + delta.Reason = "npc_state_missing" + return delta + } + switch strings.ToLower(strings.TrimSpace(intent.Action)) { + case "wait", "": + delta.Applied = true + delta.Reason = "noop" + delta.Event = &WorldEvent{ + ID: fmt.Sprintf("evt-wait-%d", time.Now().UnixNano()), + Type: "npc_wait", + Source: "npc", + ActorID: npc.NPCID, + LocationID: npc.CurrentLocation, + Content: "waits", + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + case "move": + target := strings.TrimSpace(intent.TargetLocation) + if target == "" { + delta.Reason = "missing_target_location" + return delta + } + current := state.Locations[npc.CurrentLocation] + if !containsString(current.Neighbors, target) && npc.CurrentLocation != target { + delta.Reason = "location_not_reachable" + return delta + } + npc.CurrentLocation = target + npc.LastActiveTick = state.Clock.Tick + delta.Applied = true + delta.NPCStateChanged = true + delta.Event = &WorldEvent{ + ID: fmt.Sprintf("evt-move-%d", time.Now().UnixNano()), + Type: "npc_move", + Source: "npc", + ActorID: npc.NPCID, + LocationID: target, + Content: target, + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + case "speak": + delta.Applied = true + npc.LastActiveTick = state.Clock.Tick + delta.Event = &WorldEvent{ + ID: fmt.Sprintf("evt-speak-%d", time.Now().UnixNano()), + Type: "npc_speak", + Source: "npc", + ActorID: npc.NPCID, + LocationID: npc.CurrentLocation, + Content: strings.TrimSpace(intent.Speech), + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + case "observe": + if npc.Beliefs == nil { + npc.Beliefs = map[string]string{} + } + if summary := strings.TrimSpace(intent.InternalReasoningSummary); summary != "" { + npc.Beliefs[fmt.Sprintf("tick-%d", state.Clock.Tick)] = summary + } + npc.LastActiveTick = state.Clock.Tick + delta.Applied = true + delta.NPCStateChanged = true + delta.Event = &WorldEvent{ + ID: fmt.Sprintf("evt-observe-%d", time.Now().UnixNano()), + Type: "npc_observe", + Source: "npc", + ActorID: npc.NPCID, + LocationID: npc.CurrentLocation, + Content: strings.TrimSpace(intent.InternalReasoningSummary), + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + case "interact": + targetEntity := strings.TrimSpace(intent.TargetEntity) + if targetEntity == "" { + delta.Reason = "missing_target_entity" + return delta + } + entity, ok := state.Entities[targetEntity] + if !ok { + delta.Reason = "entity_not_found" + return delta + } + if !locationVisibleWithinScope(*state, npc.CurrentLocation, entity.LocationID, 1) { + delta.Reason = "entity_not_reachable" + return delta + } + if entity.State == nil { + entity.State = map[string]interface{}{} + } + entity.State["last_actor"] = npc.NPCID + entity.State["last_interaction_tick"] = state.Clock.Tick + if count, ok := entity.State["interaction_count"].(float64); ok { + entity.State["interaction_count"] = count + 1 + } else if count, ok := entity.State["interaction_count"].(int); ok { + entity.State["interaction_count"] = count + 1 + } else { + entity.State["interaction_count"] = 1 + } + if effect := strings.TrimSpace(intent.Speech); effect != "" { + entity.State["last_effect"] = effect + } + e.applyProposedEffects(state, npc, intent, &entity) + state.Entities[targetEntity] = entity + delta.Applied = true + npc.LastActiveTick = state.Clock.Tick + delta.NPCStateChanged = true + delta.Event = &WorldEvent{ + ID: fmt.Sprintf("evt-interact-%d", time.Now().UnixNano()), + Type: "npc_interact", + Source: "npc", + ActorID: npc.NPCID, + LocationID: npc.CurrentLocation, + Content: firstNonEmpty(targetEntity, intent.Speech, "interacts"), + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + case "delegate": + delta.Applied = true + npc.LastActiveTick = state.Clock.Tick + delta.Event = &WorldEvent{ + ID: fmt.Sprintf("evt-delegate-%d", time.Now().UnixNano()), + Type: "npc_delegate", + Source: "npc", + ActorID: npc.NPCID, + LocationID: npc.CurrentLocation, + Content: firstNonEmpty(intent.TargetAgent, intent.Speech, "delegates"), + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + default: + delta.Reason = "unsupported_action" + } + return delta +} + +func (e *Engine) applyProposedEffects(state *WorldState, npc *NPCState, intent ActionIntent, entity *Entity) { + if state == nil || intent.ProposedEffects == nil { + return + } + if entity != nil { + if raw, ok := intent.ProposedEffects["resource_delta"].(map[string]interface{}); ok { + resources := map[string]int{} + if existing, ok := entity.State["resources"].(map[string]int); ok { + for k, v := range existing { + resources[k] = v + } + } + if existing, ok := entity.State["resources"].(map[string]interface{}); ok { + for k, v := range existing { + resources[k] = int(numberToFloat(v)) + } + } + for key, value := range raw { + resources[strings.TrimSpace(key)] += int(numberToFloat(value)) + } + entity.State["resources"] = resources + } + if raw, ok := intent.ProposedEffects["entity_state"].(map[string]interface{}); ok { + for key, value := range raw { + entity.State[strings.TrimSpace(key)] = value + } + } + } + if raw, ok := intent.ProposedEffects["quest_update"].(map[string]interface{}); ok { + questID := strings.TrimSpace(fmt.Sprint(raw["id"])) + if questID != "" { + quest := state.ActiveQuests[questID] + quest.ID = questID + if title := strings.TrimSpace(fmt.Sprint(raw["title"])); title != "" { + quest.Title = title + } + if status := strings.TrimSpace(fmt.Sprint(raw["status"])); status != "" { + quest.Status = status + } + if summary := strings.TrimSpace(fmt.Sprint(raw["summary"])); summary != "" { + quest.Summary = summary + } + if owner := strings.TrimSpace(fmt.Sprint(raw["owner_npc_id"])); owner != "" { + quest.OwnerNPCID = owner + } else if npc != nil && strings.TrimSpace(quest.OwnerNPCID) == "" { + quest.OwnerNPCID = npc.NPCID + } + if participants, ok := raw["participants"].([]interface{}); ok { + quest.Participants = make([]string, 0, len(participants)) + for _, item := range participants { + if v := strings.TrimSpace(fmt.Sprint(item)); v != "" { + quest.Participants = append(quest.Participants, v) + } + } + } + state.ActiveQuests[questID] = quest + } + } +} + +func (e *Engine) AppendRecentEvent(state *WorldState, evt WorldEvent, maxRecent int) { + if state == nil { + return + } + state.RecentEvents = append([]WorldEvent{evt}, state.RecentEvents...) + if maxRecent > 0 && len(state.RecentEvents) > maxRecent { + state.RecentEvents = state.RecentEvents[:maxRecent] + } +} + +func (e *Engine) Snapshot(state WorldState, npcStates map[string]NPCState, recentEvents []WorldEvent, pendingIntents int, limit int) SnapshotSummary { + if limit > 0 && len(recentEvents) > limit { + recentEvents = recentEvents[:limit] + } + active := make([]string, 0, len(npcStates)) + quests := make([]QuestState, 0, len(state.ActiveQuests)) + occupancy := map[string][]string{} + entityOccupancy := map[string][]string{} + for id, npc := range npcStates { + active = append(active, id) + loc := firstNonEmpty(npc.CurrentLocation, "commons") + occupancy[loc] = append(occupancy[loc], id) + } + for _, quest := range state.ActiveQuests { + quests = append(quests, quest) + } + for id, entity := range state.Entities { + loc := firstNonEmpty(entity.LocationID, "commons") + entityOccupancy[loc] = append(entityOccupancy[loc], id) + } + sort.Strings(active) + sort.Slice(quests, func(i, j int) bool { + return firstNonEmpty(quests[i].ID, quests[i].Title) < firstNonEmpty(quests[j].ID, quests[j].Title) + }) + for key := range occupancy { + sort.Strings(occupancy[key]) + } + for key := range entityOccupancy { + sort.Strings(entityOccupancy[key]) + } + return SnapshotSummary{ + WorldID: state.WorldID, + Tick: state.Clock.Tick, + SimTimeUnix: state.Clock.SimTimeUnix, + Locations: state.Locations, + NPCCount: len(npcStates), + ActiveNPCs: active, + Quests: quests, + RecentEvents: recentEvents, + PendingIntentCount: pendingIntents, + Occupancy: occupancy, + EntityOccupancy: entityOccupancy, + NPCStates: npcStates, + } +} + +func locationVisibleWithinScope(state WorldState, fromLocation, targetLocation string, scope int) bool { + fromLocation = strings.TrimSpace(fromLocation) + targetLocation = strings.TrimSpace(targetLocation) + if targetLocation == "" || fromLocation == "" { + return targetLocation == fromLocation + } + if fromLocation == targetLocation { + return true + } + if scope <= 0 { + return false + } + visited := map[string]struct{}{fromLocation: {}} + frontier := []string{fromLocation} + for depth := 0; depth < scope; depth++ { + next := make([]string, 0) + for _, current := range frontier { + loc, ok := state.Locations[current] + if !ok { + continue + } + for _, neighbor := range loc.Neighbors { + neighbor = strings.TrimSpace(neighbor) + if neighbor == "" { + continue + } + if neighbor == targetLocation { + return true + } + if _, seen := visited[neighbor]; seen { + continue + } + visited[neighbor] = struct{}{} + next = append(next, neighbor) + } + } + frontier = next + if len(frontier) == 0 { + break + } + } + return false +} + +func containsString(items []string, target string) bool { + target = strings.TrimSpace(target) + for _, item := range items { + if strings.TrimSpace(item) == target { + return true + } + } + return false +} + +func maxInt64(v, min int64) int64 { + if v < min { + return min + } + return v +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func numberToFloat(v interface{}) float64 { + switch n := v.(type) { + case int: + return float64(n) + case int64: + return float64(n) + case float64: + return n + case float32: + return float64(n) + default: + return 0 + } +} diff --git a/pkg/world/engine_test.go b/pkg/world/engine_test.go new file mode 100644 index 0000000..c8fd761 --- /dev/null +++ b/pkg/world/engine_test.go @@ -0,0 +1,103 @@ +package world + +import "testing" + +func TestVisibleEventsForNPCIncludesNeighborLocationsWithinScope(t *testing.T) { + engine := NewEngine() + state := DefaultWorldState() + npc := NPCState{NPCID: "keeper", CurrentLocation: "commons"} + events := []WorldEvent{ + {ID: "a", Type: "noise", LocationID: "square", Content: "neighbor event"}, + {ID: "b", Type: "noise", LocationID: "far", Content: "far event"}, + } + state.Locations["far"] = Location{ID: "far", Neighbors: []string{}} + out := engine.VisibleEventsForNPC(state, npc, events, 1) + if len(out) != 1 || out[0].ID != "a" { + t.Fatalf("expected only neighbor event, got %+v", out) + } +} + +func TestApplyIntentInteractUpdatesEntityState(t *testing.T) { + engine := NewEngine() + state := DefaultWorldState() + state.Entities["fountain"] = Entity{ + ID: "fountain", + Name: "Fountain", + LocationID: "square", + State: map[string]interface{}{}, + } + npc := &NPCState{NPCID: "merchant", CurrentLocation: "square"} + delta := engine.ApplyIntent(&state, npc, ActionIntent{ + ActorID: "merchant", + Action: "interact", + TargetEntity: "fountain", + Speech: "inspects the fountain", + }) + if !delta.Applied || delta.Event == nil { + t.Fatalf("expected interact delta to apply, got %+v", delta) + } + entity := state.Entities["fountain"] + if entity.State["last_actor"] != "merchant" { + t.Fatalf("expected entity last_actor updated, got %+v", entity.State) + } + if entity.State["interaction_count"] == nil { + t.Fatalf("expected interaction_count, got %+v", entity.State) + } +} + +func TestApplyIntentInteractRejectsDistantEntity(t *testing.T) { + engine := NewEngine() + state := DefaultWorldState() + state.Locations["far"] = Location{ID: "far", Neighbors: []string{}} + state.Entities["orb"] = Entity{ + ID: "orb", + LocationID: "far", + } + npc := &NPCState{NPCID: "sage", CurrentLocation: "commons"} + delta := engine.ApplyIntent(&state, npc, ActionIntent{ + ActorID: "sage", + Action: "interact", + TargetEntity: "orb", + }) + if delta.Applied { + t.Fatalf("expected distant interact to be rejected, got %+v", delta) + } + if delta.Reason != "entity_not_reachable" { + t.Fatalf("unexpected rejection reason: %+v", delta) + } +} + +func TestApplyIntentInteractAppliesQuestAndResourceEffects(t *testing.T) { + engine := NewEngine() + state := DefaultWorldState() + state.Entities["chest"] = Entity{ + ID: "chest", + LocationID: "square", + State: map[string]interface{}{}, + } + npc := &NPCState{NPCID: "rogue", CurrentLocation: "square"} + delta := engine.ApplyIntent(&state, npc, ActionIntent{ + ActorID: "rogue", + Action: "interact", + TargetEntity: "chest", + ProposedEffects: map[string]interface{}{ + "resource_delta": map[string]interface{}{"gold": 3}, + "quest_update": map[string]interface{}{ + "id": "find-chest", + "title": "Find The Chest", + "status": "completed", + }, + }, + }) + if !delta.Applied { + t.Fatalf("expected interact to apply, got %+v", delta) + } + resources, ok := state.Entities["chest"].State["resources"].(map[string]int) + if !ok || resources["gold"] != 3 { + t.Fatalf("expected gold resource delta, got %+v", state.Entities["chest"].State) + } + quest := state.ActiveQuests["find-chest"] + if quest.Status != "completed" { + t.Fatalf("expected quest completion, got %+v", quest) + } +} diff --git a/pkg/world/store.go b/pkg/world/store.go new file mode 100644 index 0000000..2fe35d1 --- /dev/null +++ b/pkg/world/store.go @@ -0,0 +1,232 @@ +package world + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +type Store struct { + dir string + worldPath string + npcPath string + eventsPath string + mu sync.RWMutex +} + +func NewStore(workspace string) *Store { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return nil + } + dir := filepath.Join(workspace, "agents", "runtime") + _ = os.MkdirAll(dir, 0755) + return &Store{ + dir: dir, + worldPath: filepath.Join(dir, "world_state.json"), + npcPath: filepath.Join(dir, "npc_state.json"), + eventsPath: filepath.Join(dir, "world_events.jsonl"), + } +} + +func DefaultWorldState() WorldState { + now := time.Now().Unix() + return WorldState{ + WorldID: "main-world", + Clock: Clock{ + Tick: 0, + SimTimeUnix: now, + LastAdvance: now, + TickDuration: 30, + }, + Locations: map[string]Location{ + "commons": { + ID: "commons", + Name: "Commons", + Neighbors: []string{"square"}, + }, + "square": { + ID: "square", + Name: "Square", + Neighbors: []string{"commons"}, + }, + }, + GlobalFacts: map[string]interface{}{}, + Entities: map[string]Entity{}, + ActiveQuests: map[string]QuestState{}, + RecentEvents: []WorldEvent{}, + } +} + +func (s *Store) LoadWorldState() (WorldState, error) { + if s == nil { + return DefaultWorldState(), nil + } + s.mu.RLock() + defer s.mu.RUnlock() + data, err := os.ReadFile(s.worldPath) + if err != nil { + if os.IsNotExist(err) { + return DefaultWorldState(), nil + } + return WorldState{}, err + } + var state WorldState + if err := json.Unmarshal(data, &state); err != nil { + return WorldState{}, err + } + if strings.TrimSpace(state.WorldID) == "" { + state.WorldID = "main-world" + } + if state.Clock.TickDuration <= 0 { + state.Clock.TickDuration = 30 + } + if state.Locations == nil { + state.Locations = DefaultWorldState().Locations + } + if state.GlobalFacts == nil { + state.GlobalFacts = map[string]interface{}{} + } + if state.Entities == nil { + state.Entities = map[string]Entity{} + } + if state.ActiveQuests == nil { + state.ActiveQuests = map[string]QuestState{} + } + return state, nil +} + +func (s *Store) SaveWorldState(state WorldState) error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if err := os.MkdirAll(s.dir, 0755); err != nil { + return err + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.worldPath, data, 0644) +} + +func (s *Store) LoadNPCStates() (map[string]NPCState, error) { + if s == nil { + return map[string]NPCState{}, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + data, err := os.ReadFile(s.npcPath) + if err != nil { + if os.IsNotExist(err) { + return map[string]NPCState{}, nil + } + return nil, err + } + items := map[string]NPCState{} + if err := json.Unmarshal(data, &items); err != nil { + return nil, err + } + return items, nil +} + +func (s *Store) SaveNPCStates(items map[string]NPCState) error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if items == nil { + items = map[string]NPCState{} + } + if err := os.MkdirAll(s.dir, 0755); err != nil { + return err + } + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.npcPath, data, 0644) +} + +func (s *Store) SaveNPCState(id string, state NPCState) error { + items, err := s.LoadNPCStates() + if err != nil { + return err + } + items[strings.TrimSpace(id)] = state + return s.SaveNPCStates(items) +} + +func (s *Store) AppendWorldEvent(evt WorldEvent) error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if err := os.MkdirAll(s.dir, 0755); err != nil { + return err + } + data, err := json.Marshal(evt) + if err != nil { + return err + } + f, err := os.OpenFile(s.eventsPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(append(data, '\n')) + return err +} + +func (s *Store) Events(limit int) ([]WorldEvent, error) { + if s == nil { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + f, err := os.Open(s.eventsPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer f.Close() + var out []WorldEvent + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 2*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var evt WorldEvent + if err := json.Unmarshal([]byte(line), &evt); err != nil { + continue + } + out = append(out, evt) + } + if err := scanner.Err(); err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { + if out[i].Tick != out[j].Tick { + return out[i].Tick > out[j].Tick + } + return out[i].CreatedAt > out[j].CreatedAt + }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} diff --git a/pkg/world/types.go b/pkg/world/types.go new file mode 100644 index 0000000..67475cc --- /dev/null +++ b/pkg/world/types.go @@ -0,0 +1,142 @@ +package world + +type Clock struct { + Tick int64 `json:"tick"` + SimTimeUnix int64 `json:"sim_time_unix"` + LastAdvance int64 `json:"last_advance_unix"` + TickDuration int64 `json:"tick_duration_sec"` +} + +type Location struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Neighbors []string `json:"neighbors,omitempty"` +} + +type Entity struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + LocationID string `json:"location_id,omitempty"` + State map[string]interface{} `json:"state,omitempty"` +} + +type QuestState struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + Status string `json:"status,omitempty"` + OwnerNPCID string `json:"owner_npc_id,omitempty"` + Participants []string `json:"participants,omitempty"` + Summary string `json:"summary,omitempty"` +} + +type WorldEvent struct { + ID string `json:"id"` + Type string `json:"type"` + Source string `json:"source,omitempty"` + ActorID string `json:"actor_id,omitempty"` + LocationID string `json:"location_id,omitempty"` + Content string `json:"content,omitempty"` + VisibleTo []string `json:"visible_to,omitempty"` + Payload map[string]interface{} `json:"payload,omitempty"` + Tick int64 `json:"tick"` + CreatedAt int64 `json:"created_at"` +} + +type WorldState struct { + WorldID string `json:"world_id"` + Clock Clock `json:"clock"` + Locations map[string]Location `json:"locations,omitempty"` + GlobalFacts map[string]interface{} `json:"global_facts,omitempty"` + Entities map[string]Entity `json:"entities,omitempty"` + ActiveQuests map[string]QuestState `json:"active_quests,omitempty"` + RecentEvents []WorldEvent `json:"recent_events,omitempty"` +} + +type GoalSet struct { + ShortTerm []string `json:"short_term,omitempty"` + LongTerm []string `json:"long_term,omitempty"` +} + +type NPCState struct { + NPCID string `json:"npc_id"` + ProfileRef string `json:"profile_ref,omitempty"` + CurrentLocation string `json:"current_location,omitempty"` + Goals GoalSet `json:"goals,omitempty"` + Beliefs map[string]string `json:"beliefs,omitempty"` + Mood string `json:"mood,omitempty"` + Relationships map[string]string `json:"relationships,omitempty"` + Inventory map[string]int `json:"inventory,omitempty"` + Assets map[string]interface{} `json:"assets,omitempty"` + PrivateMemorySummary string `json:"private_memory_summary,omitempty"` + Status string `json:"status,omitempty"` + LastActiveTick int64 `json:"last_active_tick,omitempty"` +} + +type NPCBlueprint struct { + NPCID string `json:"npc_id"` + DisplayName string `json:"display_name,omitempty"` + Kind string `json:"kind,omitempty"` + Role string `json:"role,omitempty"` + Persona string `json:"persona,omitempty"` + Traits []string `json:"traits,omitempty"` + Faction string `json:"faction,omitempty"` + HomeLocation string `json:"home_location,omitempty"` + DefaultGoals []string `json:"default_goals,omitempty"` + PerceptionScope int `json:"perception_scope,omitempty"` + ScheduleHint string `json:"schedule_hint,omitempty"` + WorldTags []string `json:"world_tags,omitempty"` + MemoryNamespace string `json:"memory_namespace,omitempty"` + PromptFile string `json:"prompt_file,omitempty"` +} + +type ActionIntent struct { + ActorID string `json:"actor_id"` + Action string `json:"action"` + TargetLocation string `json:"target_location,omitempty"` + TargetEntity string `json:"target_entity,omitempty"` + TargetAgent string `json:"target_agent,omitempty"` + Speech string `json:"speech,omitempty"` + InternalReasoningSummary string `json:"internal_reasoning_summary,omitempty"` + ProposedEffects map[string]interface{} `json:"proposed_effects,omitempty"` +} + +type WorldDelta struct { + Applied bool `json:"applied"` + Reason string `json:"reason,omitempty"` + Intent ActionIntent `json:"intent"` + Event *WorldEvent `json:"event,omitempty"` + NPCStateChanged bool `json:"npc_state_changed,omitempty"` +} + +type WorldTickRequest struct { + Source string `json:"source,omitempty"` + UserInput string `json:"user_input,omitempty"` + LocationID string `json:"location_id,omitempty"` + CatchUpTicks int `json:"catch_up_ticks,omitempty"` + MaxNPCPerTick int `json:"max_npc_per_tick,omitempty"` + VisibleEvents []WorldEvent `json:"visible_events,omitempty"` +} + +type RenderedResult struct { + Text string `json:"text,omitempty"` + Tick int64 `json:"tick,omitempty"` + Intents []ActionIntent `json:"intents,omitempty"` + RecentEvents []WorldEvent `json:"recent_events,omitempty"` +} + +type SnapshotSummary struct { + WorldID string `json:"world_id,omitempty"` + Tick int64 `json:"tick,omitempty"` + SimTimeUnix int64 `json:"sim_time_unix,omitempty"` + Locations map[string]Location `json:"locations,omitempty"` + NPCCount int `json:"npc_count,omitempty"` + ActiveNPCs []string `json:"active_npcs,omitempty"` + Quests []QuestState `json:"quests,omitempty"` + RecentEvents []WorldEvent `json:"recent_events,omitempty"` + PendingIntentCount int `json:"pending_intent_count,omitempty"` + Occupancy map[string][]string `json:"occupancy,omitempty"` + EntityOccupancy map[string][]string `json:"entity_occupancy,omitempty"` + NPCStates map[string]NPCState `json:"npc_states,omitempty"` +}