从零搭建 Agent Harness 系列(十)从当前基线到生产级 Harness 的完整路线

最终用户应该可以这样使用它:

1
2
3
4
5
6
7
8
9
10
$ claw

claw> 帮我检查这个项目有哪些编译错误
实时看到 Agent 的回复和工具调用
执行命令或修改文件前得到明确确认
任务很长时可以按 Ctrl-C 取消

claw> 把刚才发现的问题修好
继续复用上一轮上下文
修改完成后回到下一个 claw> 提示符

这不是单个功能,而是一条有依赖关系的工程路线:

1
2
3
4
5
6
7
8
9
10
11
Runtime 边界

连续对话

流式事件

全链路取消

工具审批

持久化、可靠性、协议、评测和部署

本篇只描述从当前基线继续往前的未来计划,不重复前面已经完成的基础能力。后续系列文章再按照本文的阶段逐步实现。

一、当前基线和最终目标

在这个提交中,入口程序仍然是一次性任务:创建 Provider、Registry、Engine 和 Session,追加一条固定 Prompt,然后调用一次 Run

1
2
3
4
5
6
sess.Append(schema.Message{Role: schema.RoleUser, Content: prompt})

err := eng.Run(context.Background(), sess, reporter)
if err != nil {
log.Fatalf("引擎崩溃: %v", err)
}

这个入口可以验证 Agent Loop、工具并发和 Trace,但不能承载长期交互。目标不是把所有逻辑塞进 main.go,而是形成以下运行时分层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Terminal / HTTP / 其他渠道
↓ 输入、展示、审批回答
Runtime
↓ Session、Run 生命周期、取消
Agent Engine
↓ Thinking / Action / Tool / Observation
Provider Adapter
↓ 同步或流式模型调用
Approval Layer
↓ Policy / Grant / Handler
Tool Registry
↓ 工具发现、风险声明、中间件和执行
Persistence / Observability
↓ Session、Grant、Trace、Usage、审计

层之间必须保持单向依赖:

1
2
3
4
5
Terminal 改变,不修改 Engine
Provider 改变,不修改 Approval
Approval 渠道改变,不修改 Tool
工具增加,不修改 REPL
Session 存储改变,不修改 Reporter

二、目标运行时状态机

一条用户任务的完整生命周期是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Idle
↓ 用户提交 Prompt
Preparing
↓ 读取 Session、压缩上下文、构造请求
WaitingResponse
↓ 模型返回完整 Assistant Message
NoToolCall ─────────────────────────→ Completed
↓ 有 ToolCall
WaitingApproval
├── Denied ────────────────────────→ Observation → Next Turn
└── Allowed

ExecutingTools

AppendObservation → Next Turn

Ctrl-C 可以从 PreparingWaitingResponseWaitingApprovalExecutingTools 进入 Cancelled

1
2
3
4
5
6
7
Cancelled

当前 Run 返回

Session 保留一致状态

Runtime 回到 Idle

这要求 Runtime 和 Engine 的职责严格分离:

1
2
3
4
5
Runtime:管理用户和任务生命周期
Engine:完成一条 Agent 任务
Provider:适配模型协议
Approval:决定工具是否可以执行
Registry:执行已经获准的工具

三、未来阶段一:建立 Runtime 边界

第一步不是马上实现 REPL,而是先明确“一条任务”和“长期运行程序”的边界。

当前 Engine 的入口已经是可复用形态:

1
2
3
4
5
func (e *AgentEngine) Run(
ctx context.Context,
session *ctxpkg.Session,
reporter Reporter,
) error

目标关系是:

1
2
3
4
5
6
7
Runtime Loop
├── 读取一条用户输入
├── 写入 Session
├── 创建本次 Run Context
├── 调用 AgentEngine.Run
├── 等待完成、失败或取消
└── 回到下一次输入

本阶段需要完成:

1
2
3
4
5
把用户输入从 main.go 移出
让 Engine 不读取 stdin
让 Runtime 不解析模型 SDK Chunk
让 Reporter 成为唯一展示出口
让同一个 Session 支持多次 Run

验收标准:可以在不修改 Engine 核心循环的情况下,替换 Terminal 为另一个输入渠道。

四、未来阶段二:连续对话 REPL

Runtime 的第一种实现是 Terminal REPL:

1
2
3
4
5
6
7
8
9
10
11
打印 claw>

读取一行 Prompt

处理本地命令

session.Append(UserMessage)

engine.Run(...)

回到 claw>

计划新增 internal/cli/repl.go,支持:

1
2
3
4
/help   查看帮助
/clear 清空 Session 历史
/exit 退出程序
/quit 退出程序

Session 必须跨轮复用:

1
2
3
用户输入 A → Append(User A) → Run(session)
用户输入 B → Append(User B) → Run(session)
用户输入 C → Append(User C) → Run(session)

/clear 只清理对话状态,不重建 Provider、Registry 和 Engine。REPL 还要区分三类错误:

1
2
3
用户退出:正常结束
当前任务取消:回到 claw>
Engine 崩溃或配置错误:显示错误并决定是否继续

该阶段对应的后续实现提交是 fb7d7450c0fa9d4d096a06f77e609c4320a55b4f

五、未来阶段三:增加流式事件协议

基线的 Provider 只有完整响应接口:

1
2
3
type LLMProvider interface {
Generate(ctx context.Context, messages []schema.Message, availableTools []schema.ToolDefinition) (*schema.Message, error)
}

目标是在保留同步能力的同时,增加可选的流式接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type StreamEventType string

const (
StreamTextDelta StreamEventType = "text_delta"
StreamCompleted StreamEventType = "completed"
StreamError StreamEventType = "error"
)

type StreamEvent struct {
Type StreamEventType
Text string
Message *schema.Message
Usage *schema.Usage
Err error
}

type StreamingProvider interface {
LLMProvider
GenerateStream(ctx context.Context, messages []schema.Message, tools []schema.ToolDefinition) (<-chan StreamEvent, error)
}

三层职责必须分开:

1
2
3
Provider:SDK Chunk → StreamEvent
Engine:StreamEvent → Agent 状态变化
Reporter:StreamEvent → Terminal 展示

Channel 只负责 Provider 和 Engine 之间的事件传递,Provider 不应该直接调用 fmt.Printf

该阶段对应的后续实现提交是 02dd5b944952fe7a9369f7e6877c4fef4b0881c9

六、未来阶段四:聚合流式 ToolCall

文本 Delta 可以立即输出,但 ToolCall 参数通常是分片 JSON:

1
2
3
{"com
mand":"go test
./..."}

所以不能把每个 Delta 直接交给工具执行。Provider 要按 ToolCall Index 聚合:

1
2
3
4
5
6
7
8
9
10
ToolCall Delta
├── ID
├── Function Name
└── Function Arguments 分片

ToolCallAccumulator

JSON 校验

schema.ToolCall

完成条件:

1
2
3
4
多个 ToolCall 的参数不互相串联
空参数可以安全转换为 {}
非法 JSON 转换为 StreamError
只有 Completed 后才进入 Engine Tool 阶段

流式文本和最终上下文必须分开:

1
2
3
Text Delta       → 立即展示
完整 Message → 写入 Session
ToolCall → 进入审批和执行

七、未来阶段五:实现 Ctrl-C 全链路取消

REPL 为每条任务创建独立的 runCtx

1
2
3
4
5
6
7
8
9
os.Interrupt
↓ cancel()
runCtx.Done()
├── Runtime 停止等待当前 Run
├── Engine 停止等待 StreamEvent
├── Provider 关闭 HTTP Stream
├── sendStreamEvent 停止阻塞
├── bash 子进程被终止
└── Approval Handler 返回取消

取消的用户语义是:

1
2
3
4
5
取消当前任务
保留已确认的 Session 历史
不执行未开始的危险工具
回到 claw>
不退出整个进程

每一个可阻塞组件都必须继续传递同一个 Context。不能在 Provider、Tool 或 Handler 内部重新使用无法取消的 context.Background()

该阶段对应的后续实现提交是 4a9be525a8e0f5ef24014c7c178f7ac8a8704d2b

八、未来阶段六:引入通用 Approval Gate

工具执行从:

1
ToolCall → Registry.Execute

改为:

1
2
3
4
5
6
7
8
ToolCall
↓ RiskLevel
Policy
↓ GrantStore
↓ Handler
Decision

Registry.Execute

Approval 层拆成四个职责:

1
2
3
4
RiskLevel:工具能力的风险分类
Policy:默认自动允许、询问或拒绝
GrantStore:保存会话授权
Handler:Terminal 等渠道如何询问用户

需要新增:

1
2
3
4
5
6
internal/approval/interface.go
internal/approval/policy.go
internal/approval/grant.go
internal/approval/gate.go
internal/approval/terminal_approval.go
internal/approval/id.go

执行顺序必须是:

1
2
3
4
5
6
7
所有 ToolCall 串行 Check
├── Deny:生成拒绝 Observation
└── Allow:加入 approvedCalls

approvedCalls 并发 Execute

按原始 index 写回 Observation

不能把审批放到执行 Goroutine 中,否则多个请求会同时读取同一个 Terminal 输入流。

该阶段对应的后续实现提交是 8a13d9a876a86a08c2ee43171cc2a108412f18ec

九、未来阶段七:补齐资源生命周期和可靠取消

阶段一至六跑通后,先处理最容易在生产环境暴露的问题:

1
2
3
4
5
6
Provider Goroutine 是否全部退出
Approval 输入 Goroutine 是否泄漏
HTTP Stream 是否关闭
bash 子进程是否被回收
取消后的结果是否误写 Session
Reporter 并发输出是否可读

需要增加:

1
2
3
4
5
6
7
统一任务状态
工具级超时
并发上限
Channel 背压
优雅关闭
Race Detector
取消、超时和泄漏测试

尤其要解决 Terminal Handler 中“Context 已取消但 ReadString 仍阻塞”的问题。函数返回 context.Canceled 不代表后台输入 Goroutine 一定已经退出。

十、未来阶段八:审批持久化和最小权限

内存 Grant 只能用于单进程实验。生产级授权需要:

1
2
3
4
5
6
7
8
文件或数据库 GrantStore
工作区范围
文件路径范围
命令前缀或参数摘要
过期时间
撤销能力
审批人和审批时间
审计记录

授权不能只匹配 SessionID + ToolName。允许一次 bash 不应自动等于允许该会话执行任意命令。

风险还要逐步细化到参数级:

1
2
3
4
read_file(workspace/a.go)  低风险
write_file(workspace/a.go) 中风险
bash("go test ./...") 可配置风险
bash("rm -rf ...") 高风险

这一步还要建立工作区沙箱、路径规范化、命令策略和完整审计。

十一、未来阶段九:Provider 可靠性和成本预算

生产模型调用需要处理:

1
2
3
4
5
6
7
8
连接超时
首 Token 超时
整体响应超时
429 限流
5xx 服务错误
流式半响应
模型能力不匹配
重试和退避

还要建立预算控制:

1
2
3
4
5
单 Turn 最大 Token
单 Run 最大 Token
Session 累计费用
工具执行时间预算
Subagent 次数预算

涉及副作用 ToolCall 时,重试必须考虑幂等性,不能因为 HTTP 重试而重复执行写文件或 bash。

十二、未来阶段十:Session 持久化和任务恢复

内存 Session 只能支持单进程运行。生产级 Session 需要保存:

1
2
3
4
5
6
7
Session 元数据
Message History
Working Memory
当前 Run 状态
最后一次 ToolCall
取消或失败原因
Usage 和成本

重启恢复时要判断任务停在:

1
2
3
4
5
等待模型
等待审批
工具执行中
已取消
已完成

只有状态和 Observation 一致时才能继续。若进程在副作用工具执行中崩溃,必须进入人工确认、幂等重试或补偿流程,不能盲目重复执行。

十三、未来阶段十一:工具生态和 MCP / A2A

本地 Registry 稳定后,接入外部工具和远程 Agent:

1
2
3
4
5
6
7
8
MCP Server
↓ tools/list
MCP Adapter
↓ schema.ToolDefinition
Tool Registry
↓ Approval Gate
↓ tools/call
MCP Server

需要解决:

1
2
3
4
5
6
7
工具发现和刷新
远程连接生命周期
远程超时和取消
远程错误映射
工具版本兼容
远程风险声明
Agent-to-Agent 身份和权限

外部工具及远程 Agent 必须复用同一个 Context、Approval 和 Observability 链路,不能因为工具来自 MCP 就绕过本地安全边界。

十四、未来阶段十二:评测、性能和部署治理

测试与评测

生产级 Harness 不能只依赖手工运行,需要:

1
2
3
4
5
6
7
8
Provider 协议测试
StreamEvent 顺序测试
ToolCall 聚合测试
REPL 和 Ctrl-C 测试
Approval Gate 表驱动测试
并发和 Race 测试
Fake Provider 回放测试
固定任务回归测试

还要评估 Agent 行为:

1
2
3
4
5
6
任务是否完成
是否调用正确工具
是否产生越权操作
是否陷入 Doom Loop
Token 和耗时是否超预算
取消后状态是否一致

性能与规模化

需要关注:

1
2
3
4
5
6
7
8
9
首 Token 延迟
完整 Run 延迟
并发 Run 数
Provider 连接复用
工具执行并发池
上下文缓存
Trace 异步写入
大输出截断
资源配额

部署与治理

最后把本地 CLI 变成团队可以依赖的服务:

1
2
3
4
5
6
7
Secret 管理
用户认证和租户隔离
工作区沙箱
权限审计
指标、日志、Trace
健康检查和优雅关闭
SLO、告警、灰度和回滚

十五、阶段依赖和提交规划

未来阶段不能随意调换顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
阶段一 Runtime 边界

阶段二 连续 REPL

阶段三/四 Stream 和 ToolCall 聚合

阶段五 全链路取消

阶段六 Approval Gate

阶段七 资源生命周期可靠性

阶段八 权限持久化

阶段九 Provider 容错和预算

阶段十 Session 恢复

阶段十一 MCP / A2A

阶段十二 评测、性能和部署

代码提交可以保持小步可运行:

1
2
3
4
5
6
7
8
9
10
11
47452b3  当前起点

fb7d745 阶段二:REPL 和 Session 复用

02dd5b9 阶段三/四:Stream 和 ToolCall 聚合

4a9be52 阶段五:Ctrl-C 和 Context 取消

8a13d9a 阶段六:Policy、Gate、GrantStore、Terminal Handler

后续提交 阶段七至十二的生产化能力

每个阶段都必须保持可运行:

1
2
3
4
5
6
完成阶段二后:仍可非流式运行
完成阶段三后:不支持流式的 Provider 仍可回退
完成阶段五后:取消当前 Run,REPL 不退出
完成阶段六后:安全工具自动执行,危险工具需确认
完成阶段十后:进程重启可以恢复一致任务状态
完成阶段十二后:具备部署、告警、回滚和评测能力

十六、最终验收标准

连续对话

1
2
3
4
输入任务 A
任务完成后仍停留在 claw>
输入任务 B
Agent 能引用任务 A 的上下文

实时输出

1
2
3
模型生成期间持续出现文本
文本结束后有明确换行
ToolCall 参数完整后才执行

中断

1
2
3
4
模型流式输出时 Ctrl-C
bash 执行时 Ctrl-C
审批等待时 Ctrl-C
三种情况下都返回 claw>,不退出进程

审批

1
2
3
4
5
6
read_file 自动允许
write_file 询问用户
bash 询问用户
允许一次只影响当前调用
允许本会话可以复用授权
拒绝后生成 Observation,不执行工具

生产稳定性

1
2
3
4
5
6
7
Provider 错误可分类、重试或降级
Context 取消不会泄漏 Goroutine
工具结果顺序稳定
Session 可以恢复
Trace 可以回放
权限决策可以审计
指标和成本可以查询

总结

47452b38c047ba8e614369a099a04c2bbad90c83 到生产级 Agent Harness,未来要完成的是十二个连续阶段:

1
2
3
4
5
6
7
8
9
10
11
12
Runtime 边界
连续 REPL
流式事件
ToolCall 聚合
全链路取消
Approval Gate
资源生命周期可靠性
权限持久化
Provider 容错和预算
Session 恢复
MCP / A2A 生态
评测、性能和部署治理

系列十一、十二、十三会先落地其中的 REPL/Stream、中断和审批;但它们只是完整路线中的早期阶段。只有把后续的权限、恢复、评测、性能和部署工作继续完成,go-tiny-claw 才真正具备生产级 Agent Framework 的能力。