Files
Agent-System-Documents/docs/proposals/2026-10-07-agent-stack-n3-plan.md
T
weichen c13c79ebac Propose the N3 implementation plan: durability and observability
Why: N2 is closed; per the completion doc's dependency order N3 is
next and needs a plan grounded in current code (the crash window
between persist and intent commit is unprotected, the product
conversation path has zero durability, span names/attributes do not
follow the GenAI convention, the memory port does not exist).
Evidence: five-area survey with file:line citations at the pinned
baselines (agent-native 74aa60b, backend 7980a8a, core 0b5c774);
audit N1/N21 verified fixed, N6/N20 verified still open.
Impact: documentation only; work starts after this plan merges.
Refs: 2026-10-02-agent-stack-completion.md N3 section
2026-10-07 14:55:37 +00:00

22 KiB
Raw Blame History

方案:N3 持久与可观测——现状核验与增量实施

日期:2026-10-07。上游:2026-10-02-agent-stack-completion.md「N3 持久与可观测」节(范围与三条验收的唯一权威)、2026-10-02-agent-stack-audit.md(审计项逐条对当前代码复核后引用)。

基线 pin:agent-native 74aa60b、backend 7980a8a、agent-squad 6306451、core 0b5c774(均为已合入 main)。本文所有 file:line 以这些 SHA 为准;实施时先复核再引用。

依赖状态:N1/Q1/I1/I2/N2 全部闭卡(任务卡归档)。总提案依赖序「N3 在 N2 之后」满足,N3 开工。

一、现状核验(五路侦察 + 关键处亲验)

1. RunState 可恢复(验收①)

已就位(库层、native 适配路径):

  • 意图日志:runOne 先 commitToolIntent(记 CallID/参数/Replay 声明)再执行(runtime/loop.go:649-654,738-749;session/intent.go:9-25);执行失败于写意图即产 IsError 配对。
  • 恢复配对:recoverPending(loop.go:707-736)对未配对意图——存储声明与注册表现行声明双 safe 才重放(completion doc row 75 的不变式),否则补「副作用未知,未重试」IsError 配对。任一情形配对不变式都成立。
  • 预算停配对:appendSkippedToolResults(loop.go:452-469,N1 已修,回归 loop_budget_test.go:78)。
  • 毒化轮转:rotateIfPoisoned + .native-last-failure 落盘(adapters/native/backend.go:529-593,N21 已修,跨进程回归 r2_regression_test.go:19)。
  • 回归测试:durable_test.go 四组(safe 重放一次/unsafe 不重放/存储 safe+现行 never 不重放/follow-up 持久序)。

仍开放:

  • 崩溃窗口无保护:persist(assistant)(loop.go:265)→ 首个 commitToolIntent(loop.go:650)之间被杀,转录留下带 tool_calls 的 assistant 且零意图;recoverPending 无计可施,新 user 消息追加在孤立尾巴后,下一个请求被提供方拒绝。无任何与意图无关的尾部配对兜底。
  • 产品会话路径零持久:llm_direct.go:172-193 调 nruntime.Run 不传 Session——意图日志/恢复配对在产品会话 run 上全部不生效;中断后部分 parts 只存内存,seal 即丢。这是验收① 对产品的真缺口。
  • 无 mid-turn RunState 类型(turn 序号/在途调用/累计用量);runtime/checkpoint 是文件回滚日志,不是循环状态(checkpoint.go:13-22)。
  • 无「杀进程中途」测试:现有 durable 测试手喂意图,没有覆盖 persist→intent 窗口。
  • N6 残余:Stop.Replacement 仍把内存 assistant 改为替换文(loop.go:339-347),磁盘上 assistant 是原文——内存/转录分叉跨重启可见(追加的 user 替换消息只缓解了「模型能看到」)。
  • 子代理优雅停机不续跑:InterruptRunning 对所有子 run 写 done.json{status:"interrupted"}(runmgr.go:1658),而 ResumeInterruptedChildren 遇 done.json 即跳过(subagent_transcript.go:119)——WP5 续跑只对 SIGKILL 式死亡生效,优雅停机语义上反而不续。

2. 文件检查点可回滚(验收②)

已就位:

  • 库 checkpoint 包:append-only JSONL(Record{Seq,Anchor,Root,Path,Existed,Before,After})、Plan/Rewind、内容漂移跳过(stateRestore/Already/Skip,checkpoint.go:194-229)、包注释与结构双重保证「shell/python 不追踪」。
  • write/edit/multi_edit 三工具经 WorkspaceToolsCheckpointed 挂 mark(registry.go:370-394;builtin.go:111-117,153-168,333-353)。
  • backend rewind:每会话 .agentx/checkpoints.jsonl(rewind.go:12-17)、anchor=用户消息 ID、HTTP 两路由(server.go:169-170)、regenerate 复用(borrow.go:218)、忙时 409。
  • ZCode 边界全符合:只动文件不回滚历史(追加 system 消息而非删除)、不透明续跑(regenerate 显式起新 run)。
  • 测试:checkpoint_test.go 两组、checkpoint_hook_test.go、rewind_test.go(backend 端到端)。

仍开放(收口项,无根缺):

  • 回滚资格隐式(struct 有 mark 字段即入集):无导出断言/注册表查询,未来工具可静默入集或出集。
  • restore 用 os.WriteFile 跟随符号链接(checkpoint.go:241)——fs_write_file 有符号链接防护(fs.go:142-146),回滚路径没有。
  • Rewind 顺序恢复、首错即返(checkpoint.go:108):中途失败留下部分恢复,且部分态不进 Result。
  • 无保留策略:Record 每次全读算 Seq(checkpoint.go:57-62),日志无界增长。
  • HTTP 层无测试;root 移动、空 ordered 边界(selected() checkpoint.go:146)未测。
  • backend 自家工具面只有 fs_write_file 挂检查点(tools/fs.go:158-162),无 fs_edit/fs_multi_edit(产品面不暴露编辑工具)——验收② 的 write/edit/multi_edit 语义在库与适配层成立,验收测试按层分配(见 WP3)。

3. Memory 端口

已就位(全是产品侧资产):memory_entries 表(migrate v13)+ PutMemory/ListMemory(platform.go:137,157,仅 INSERT、无 scope 过滤无语义排序);HTTP 手工面(server.go:245-246);被动 recall(runmgr.go:409-426 全量过期过滤 + ctxasm.InjectMemory ≤20 条注入系统消息);session_note、plans/todos;预置 prompts(aux-session-memory.md、agent-memory-extractor.md,无消费者);AgentSpec.MemoryScope 已解析(agentspec.go:26,152-153)零消费。

仍开放:库内零 memory 面(端口不存在);无写回路径(模型不可自服务,回合后抽取未接线);recall 无 scope 过滤;MemoryScope 无消费。

4. OTel GenAI span(验收③)

已就位:telemetry 包(Span/StartFunc/手写 OTLP HTTP JSON 导出,telemetry.go:18-106);publicAttrs 拒绝名单丢 prompt/messages/content 且被测试钉住(telemetry.go:53-62,89;telemetry_test.go:35-41)——验收③「默认不含提示词正文」已满足且有机器证据;Trace 钩子三处 span(loop.go:174 invoke_agent、:407 chat、:655 execute_tool);backend 全线注入(app.go:87,258,284-285 → llm.go/sub_loop)。

仍开放:

  • span 名无后缀:约定是 chat <model>/execute_tool <tool>/invoke_agent <agent>,现名裸词。这是验收③ 的主缺口。
  • 属性未 gen_ai.*:缺 gen_ai.system、gen_ai.request.model、gen_ai.tool.name、gen_ai.usage.input_tokens/output_tokens(token 在循环有,从未进 span)。
  • 无 trace 关联:每 span 随机新 TraceID(telemetry.go:33),无 parentSpanId,Span 无 Parent/Status/Kind 字段。
  • 配置面只有环境变量 OTEL_EXPORTER_OTLP_ENDPOINT(telemetry.go:30),无 settings 项。
  • resource 无 service.name;导出错误丢弃(:46);属性值全 string(token 应 intValue)。

5. 成本「上一请求/累计」

已就位:Usage.Last* 三字段(types.go:90-99)+ 循环覆盖(loop.go:262-264);预算检查用 LastPromptTokens(loop.go:872-873,正确——prompt 成本按整段历史计);message_end 事件带单请求值(loop.go:269)。

仍开放:cache tokens 从不累计(loop.go:259-264 只动 Prompt/Completion/Total);"usage" 事件只带累计(loop.go:853-855,且 budgetStop/错误路径不发);core.Usage 无 Last*/cache 字段(modules/core model.go:205-213);子代理 run 零 token 记录(runmgr.go:1399 只记 DurationMs;sub_loop 不调 AddUsage);前端只显示 total(AgentTracker.tsx:79-83);无逐请求台账。completion 预算用累计(loop.go:875)经复核为正确语义(run 级输出预算),不动。

6. 模型回退链(基本已修,剩收口)

已就位:库 WithFallback(llm/fallback.go:7-47——5xx 且未出增量才切换,测试 fallback_test.go 三组);backend ChatFallbacks 解析(model.go:65-81)+ 消费(llm_direct.go:103、sub_loop.go:61 chat 档子代理)+ noteModelSwitch 通知与 servedModel 落账(llm_direct.go:210-240)。回退条目共享主端点(记录为有意)。

仍开放(收口项):verifier 与 lightweight 档未接;切换无可观测事件类型(仅 process part 文案);agent-native CLI 未用 WithFallback。

7. 流式错误默认重试

已就位:WithRetry(providers.go:447-524——Retryable 分类 408/429/529/≥500 与容量文案、Retry-After 上限 30s、指数退避加抖动、只在未出增量时重试、上下文溢出不重试;providers_test.go 五组钉住);backend runtime.llmRetries 默认 2 接直连(llm.go:208-209;settings.go:43,94);agent-native CLI 接 2 次(main.go:121)。

仍开放:库循环无默认——重试是宿主 opt-in,子代理/verifier/squad 路径未接(sub_loop.go:52-59 无 WithRetry);onEvent 为 nil,重试不可观测;run 记录无重试次数。

二、工作包划分

WP 内容 主要仓 对应验收
WP1 RunState 收口:崩溃窗口尾部配对兜底 + 杀窗口测试 + N6 残余 + 子代理优雅停机续跑语义 agent-native + backend ①(库层)
WP2 产品会话路径持久化:llm_direct 接 Session(transcript.jsonl),产品会话 run 获得意图日志与恢复配对 backend ①(产品层)
WP3 检查点收口:资格断言、符号链接防护、部分恢复进 Result、保留策略、HTTP 与边界测试 agent-native + backend ②
WP4 OTel GenAI 对齐:span 名后缀、gen_ai.* 属性、trace 关联与 parent、resource、settings 面 agent-native + backend ③
WP5 Memory 端口:runtime/memory 包 + 循环 recall 消费点 + MemoryScope + backend scope 实现 + memory_write 工具 agent-native + backend —
WP6 计费拆分与回退/重试收口:cache 累计、usage 事件与 run 记录带 Last*/cache、子代理 token、库默认重试、可观测事件 agent-native + core + backend + frontend —

依赖:WP1 先行(验收① 库层核心,WP2 依赖其兜底语义);WP2 随后(产品落点);WP3/WP4/WP5 独立可并行;WP6 收尾(含 core 字段级联与重试语义变化,WP6 后做总装与 A/B)。每 WP 走既有链式(库 PR → 级联 → backend PR → Dev gitlink PR),逐 WP 合入。

WP1 RunState 收口(验收① 库层)

  1. 尾部配对兜底:recoverPending 之后加 repairOrphanTail——Load 后若活动分支尾部是带 tool_calls 的 assistant 且其 ToolCalls 存在无结果项,对每个无结果调用补一条 tool 消息(Content: "进程中断,该调用未执行", IsError: true)并 persist。与意图路径正交:意图已配对的调用不受影响(按 CallID 判重)。这样无论死在哪个窗口,下次 Run 前转录配对恒成立。这是验收① 的机器核心。
  2. 杀窗口测试:循环测试钩子(在 persist(assistant) 之后、首个 intent 之前让测试能停住循环,或直接构造该转录形态——两态都测:窗口内死亡(无意图)与执行中死亡(有意图未配对)),断言:恢复后 assertToolPairs 双向成立 + 兜底结果文案正确 + 已配对项零重复。
  3. N6 残余:Stop.Replacement 不再改内存 assistant(只追加 user 替换消息)——内存与转录一致;loop_harness_test.go:17-63 同步改断言(替换文案不再出现在 res.Messages 的 assistant 上,而是新 user 消息)。行为变化入卡。
  4. 子代理优雅停机续跑语义:done.json 按 status 分流——completed/failed/cancelled 挡续跑;interrupted 不挡(优雅停机=可续跑现场)。ResumeInterruptedChildren 改判据并补回归(模拟 InterruptRunning 路径后仍被续跑一次且仅一次)。

WP2 产品会话路径持久化(验收① 产品层)

  1. llm_direct.go 的 directRun 打开会话工作区 transcript:<workspaceRoot>/<owner>/<conv>/.agentx/transcript.jsonl,经 runtime.Session 传入 nruntime.Run——产品会话 run 获得意图日志、恢复配对与(WP1-1 的)尾部兜底。
  2. 历史来源对齐:现 History 来自 store 重放;Session 模式以 transcript 为准(库语义:Session 非空时 transcript 是权威,History 仅在 transcript 为空时作种子——llm.History 的既有语义 loop.go:76-78)。迁移语义:首次启用时 transcript 为空 → 用 store 历史种子;此后 transcript 权威。种子与续接的一致性测试(不丢不重)。
  3. 不做透明续跑(ZCode 边界,入卡):ReconcileInterrupted 对会话 run 仍 seal interrupted;配对修复在下一条用户消息触发的新 run 前完成(WP1-1 兜住),用户看到的是「中断」标记 + 可继续对话,不是自动重跑。
  4. 端到端测试:产品路径杀窗口形态 → 新 run 前配对成立 + 用户可继续发消息(种子不丢历史)。

WP3 检查点收口(验收②)

  1. 资格断言:导出 Registry.Checkpointed(name) bool(struct mark 非空即真)或同语义测试辅助;测试钉住入集集合恰为 {write_file, edit_file, multi_edit}(库)与 {fs_write_file}(backend 自家面),防静默漂移。
  2. 符号链接防护:restore 落盘前 Lstat——目标为符号链接时跳过并进 Skipped(与 fs_write_file 的防护同语义)+ 测试。
  3. 部分恢复进 Result:Rewind 单文件失败不中止,记录 Failed []string 续跑其余;Result 增字段(additive);测试(一个失败一个不失败)。
  4. 保留策略:按 anchor 裁剪——Log.Compact(keepAnchors int) 只保留最近 N 个 anchor 的记录(重写日志文件);backend 在 RewindFiles 成功后以 keepAnchors=200 调用;Record 的 Seq 改由文件尾行号维护(去全读)。
  5. 测试补齐:HTTP happy + busy 409(server 层);root 移动场景(restore 按记录内 Root 恢复,不窜根);空 ordered 边界行为钉住;「外部同字节改写」分类为 Already 的语义钉住。

WP4 OTel GenAI 对齐(验收③)

  1. span 名带后缀:invoke_agent <agent|agent-native>、chat <model>、execute_tool <tool>(loop.go 三处改名;适配器/宿主传 agent 名——invoke_agent 的 agent 名用 Config.ExecutionID 或新增 Config.AgentName,默认 "agent-native")。
  2. gen_ai.* 属性:gen_ai.system(openai/anthropic,由客户端协议名上抛——Stream 事件或 Config 显式字段,取后者简单:Config.ProviderName string,宿主可设,空=openai);gen_ai.request.model(chat span);gen_ai.tool.name(execute_tool span);gen_ai.usage.input_tokens/output_tokens(chat span 结束时由该轮 Usage 填入)。
  3. trace 关联:一次 Run 共享一个 TraceID,invoke_agent 为根、chat/execute_tool 为其子(parentSpanId)。Trace 钩子签名扩展:Trace func(name string, attrs map[string]string) func() 保持,Span 增 ParentSpanID/TraceID 注入位(telemetry.StartFunc 返回的闭包按 name 层级维护栈——invoke_agent 入栈为根,出栈弹出;简单确定性强)。
  4. resource 与导出:resource 增 service.name=agent-native(宿主可覆盖:telemetry.StartFunc 的选项参数);token 属性 intValue;导出失败记日志(不静默吞)。
  5. settings 面:telemetry.otlpEndpoint(默认 "" = 不导出,保持现状语义;环境变量继续有效,settings 覆盖环境);telemetry.serviceName(默认 agent-native)。两处 default 的行为不变证明测试。
  6. 测试(验收③ 机器证据):span 名后缀与属性键枚举断言(钉住约定);一次 Run 的 span 共享 TraceID 且 parent 链正确;token 进 chat span 且为 intValue;拒绝名单保持(prompt/content 永不序列化——既有测试原样通过)。

WP5 Memory 端口

  1. runtime/memory 包:Entry{ID,Scope,ProjectID,Type,Content,Source,Confidence,CreatedAt,ExpiresAt} + RecallQuery{Scope,ProjectID,Limit} + Store interface{ Recall(ctx,q) ([]Entry,error); Append(ctx,e Entry) error }。端口形态对齐 squad/runstore.go 的先例(库定义+宿主实现)。
  2. 循环消费点:Config.Memory memory.Store(nil=不启用)+ Config.MemoryScope string(对应 AgentSpec.MemoryScope 的消费);prepareRequest 时在系统前缀后追加 recall 注入块(替换文案形态与 ctxasm.InjectMemory 对齐:[记忆]\n- (typ) content,上限 20 条,确定性排序)——行为对齐测试(端口注入的条目出现在请求里、nil 时无变化)。
  3. backend 适配:storeMemory{st, owner} 实现端口(Recall=ListMemory scope/过期过滤+Limit;Append=PutMemory);llm.go 装配把现 Memories() 断言路径(llm.go:231-233 + runmgr.go:409-426)迁到端口(语义等价:同条目同排序同文案——迁移前后注入文本一致的对照测试)。AgentSpec.MemoryScope 经 squad/native 适配传入 Config.MemoryScope(plugin agents 的画像驱动 scope)。
  4. memory_write 工具:库内工具(模型自服务写回;参数 {scope,type,content,expires_in_days?},经 Config.Memory.Append 落库;未配置不注册);scope 校验(user|project)与类型校验(fact|preference|decision|episode|procedure)fail-closed。
  5. 后续项(入卡剩余项,不在本 WP):回合后抽取接线(agent-memory-extractor 子代理 + SessionEnd 事件驱动)、AllowStore 持久化形态(N1 deferred)。

WP6 计费拆分与回退/重试收口

  1. cache tokens 累计:loop.go:259-264 增 CacheRead/Write 累计与 Last* 对应位(Usage 增 LastCacheReadTokens/LastCacheWriteTokens?——决策:Last* 只对 Prompt/Completion/Total 已有三字段,cache 只入累计位,避免字段膨胀;记录决策依据)。
  2. usage 事件与 run 记录:emitUsage 带 Last*+cache;settle 之外的路径(budgetStop/错误)也发一次终态 usage(口径=当时累计,标注终止原因在既有错误面);core.Usage 增 LastPromptTokens/LastCompletionTokens/CacheReadTokens/CacheWriteTokens(additive JSON,core 仓 PR+级联);backend AddUsage 链路上抛新字段;子代理 token 记账(sub_loop AddUsage 接线,finishChild 写入 child.Usage 的 token 字段)。
  3. 前端:contracts.ts Usage 增可选字段;UsageBadge 显示「上一请求 / 累计」(如 +12.3k · 累计 98.1k)。
  4. 库默认重试:Config.RetryAttempts(默认 2,0=关)——Run 内包装 cfg.Stream = llm.WithRetry(cfg.Stream, attempts, onEvent);backend 直连的显式包装退役(防双重),子代理/verifier/squad 经库默认获得(行为变化:这些路径从 0 次变 2 次重试——入卡;onEvent 接 runCtx 事件面,auto_retry_start/end 成为可见事件)。
  5. 可观测:auto_retry_start/end 与 model_fallback_switch 事件类型入事件面(库 Event 类型 + backend 映射 + 前端展示沿用现有 process part 形态)。
  6. 回退链接 verifier/lightweight:verifier 的流包 WithFallback(lightweight 档回落主档的既有语义不动);CLI 记录边界(不接)。

三、验收总表

总提案验收 机器证据 所在 WP 现状
① 回合中途杀进程,恢复后工具配对仍成立 库:TestResumePairsOrphanTailAfterCrashWindow(无意图窗口)+ 既有 durable 四组(原样保持);产品:TestDirectRunPairsAfterKillSeed(种子不丢+配对成立) WP1+WP2 库有意图路径覆盖、窗口无保护;产品零持久
② 回滚只撤销 write / edit / multi_edit 资格集合钉住测试(库 {write_file,edit_file,multi_edit}、backend {fs_write_file})+ 既有 rewind/checkpoint 测试原样通过 + shell 文件存活断言(既有) WP3 结构性成立、断言隐式
③ span 名与约定一致,默认不含提示词正文 TestSpanNamesFollowGenAIConvention(名称后缀+gen_ai.* 键枚举+intValue token+TraceID 共享+parent 链)+ 既有 publicAttrs 拒绝名单测试原样通过 WP4 正文拒绝已有;名称/属性/关联未对齐

四、边界与非目标

  • 不做透明续跑(ZCode 边界):配对修复后等新用户消息;会话 run 仍 seal interrupted。WP5 子代理续跑(重派新 run)是既有有意设计,不扩展到会话 run。
  • 不回滚对话历史(ZCode 边界):rewind 只动文件,WP3 不碰消息面。
  • 不做 memory 回合后抽取接线、AllowStore 持久化、RAG 语义检索 memory_entries(WP5 是端口与接线,召回排序升级留后续)。
  • 不做 OTel SDK 引入/gRPC/batch/采样(保留手写 HTTP JSON 导出;WP4 只做语义对齐与可靠化小修)。
  • 不做 completion 预算口径改动(loop.go:875 经复核为正确语义)、prompt 预算口径改动。
  • 不做 回退条目独立端点(共享主端点记录为有意)、CLI 的 fallback 接线(记录边界)。
  • 审计 N1/N21 已修只引用;N6 随 WP1 关闭;N20 随 WP6 关闭(预算语义不动,展示/记录口径拆分)。

五、风险与回退

  • 产品路径接 Session(WP2):transcript 成为权威是会话语义变化——种子迁移必须保「不丢不重」(WP2-2 的对照测试为放行门槛);出问题回退 = 不传 Session(单点开关,代码内常量而非设置项,回退 PR 即可)。
  • N6 行为变化(WP1-3):Stop.Replacement 的展示形态变化(assistant 不再显示替换文,替换文以 user 消息出现)——guardrails 消费方(verify 回路)逐一回归。
  • WP6 重试默认化:子代理/verifier/squad 从 0 变 2 次重试——超时/配额消耗上升;RetryAttempts=0 可全关(测试钉住 0=不重试)。
  • core.Usage 加字段:additive JSON 兼容旧记录;级联链 core PR → backend PR → frontend PR → Dev。
  • done.json 语义分流(WP1-4):interrupted 从「挡续跑」变「可续跑」——优雅停机后重启会多一次重派;幂等由 resumed.json 保证,测试钉住「一次且仅一次」。