作者:互联网 时间: 2026-08-28 09:44:55
Harness Engineering:让 Agent 在受控边界内运行并不只看表面做法,关键还要理解相关条件、限制和后续影响。
做 AI 桌面端工具时,给模型接工具并不难:注册一个 schema,写一个执行函数,模型就能发起调用。真正麻烦的是后半句——它能访问哪个目录?能不能写文件?要不要用户确认?取消后副作用怎么收尾?出了问题又该查谁?

这些问题不会因为 Prompt 写了“请谨慎操作”就自动消失。团队在研究 Cherry Studio 时,最值得借鉴的一点正是:模型负责提出动作,运行时负责决定动作是否能在正确边界内发生。
这层承上启下的控制系统,就是 Harness。
Prompt、Context 和 Loop 解决了模型如何理解任务、获得事实、推进多步骤执行的问题。Harness 解决的是另一个问题:
Harness 不是单个类,而是运行时的控制面:
flowchart LRusers["用户 / 外部渠道"] --> sessionGuard["会话与工作区校验"]sessionGuard --> toolPolicy["工具暴露与权限策略"]toolPolicy --> agentDriver["模型 / Agent Driver"]agentDriver --> execution["审批、工具执行、流式事件"]execution --> runtimeState["持久化、观测、恢复与 UI"]
它通常包括:
Harness 是将 Agent 从 Demo 变成产品的关键层。没有它,模型输出的工具调用只是未经约束的建议。
工具有至少四个不同状态:
registered工具在系统中存在visible 工具描述与 schema 进入模型上下文callable模型本轮可以发起调用executable运行时检查、审批通过后真正执行
它们不能合并为一个布尔值。否则“工具已经注册”很容易被误解为“这次调用一定能执行”。
例如一个删除文件的工具:
| 状态 | 结果 |
|---|---|
| registered | 工具实现已随应用安装。 |
| visible | 当前 Agent 能知道该工具存在。 |
| callable | 工具没有被用户禁用,且当前工作区满足前置条件。 |
| executable | 当前调用获得用户批准,输入也通过路径和权限校验。 |
Prompt 可以解释工具使用策略,但只能影响模型是否尝试调用;Harness 决定工具是否真正暴露和执行。
Cherry Studio 有两条不同的工具适配路径:
flowchart LRsubgraph chatPath["普通 Chat"]chatRegistry["AI SDK ToolRegistry"] --> chatTools["内置工具 / MCP 工具 / meta tools"]chatTools --> chatAgent["AI SDK Agent"]endsubgraph sessionPath["Agent Session(Claude Code)"]sessionDescriptors["Claude Code tool descriptors + MCP servers"] --> sessionPolicy["disallowedTools + canUseTool"]sessionPolicy --> sessionDriver["Claude Code Runtime Driver"]end
两条路径共享产品能力,但不共享同一个工具注册实现。原因是 AI SDK 和 Claude Agent SDK 的工具协议、会话模型与权限回调不同。
这要求产品团队把领域能力与SDK 适配分开:
领域能力:KnowledgeService、FileManager、WebSearchService工具契约:schema、描述、权限级别、输出映射SDK 适配:AI SDK Tool / Claude MCP descriptor
不要把业务逻辑写进某个特定模型 SDK 的 tool callback;否则新增 Driver 时会复制权限和审计逻辑。
普通 Chat 使用 ToolRegistry。每个注册项包含名称、namespace、描述、defer 策略、工具实现与 applies(scope) 谓词。
typeToolEntry = {name: stringnamespace: stringdescription: stringdefer: "never" | "always" | "auto"tool: Toolapplies?: (scope) =>boolean}
工具选择应按请求动态计算,而不是在应用启动时固定:
functionresolveActiveTools(registry, requestScope) {active = []for (entry of registry.entries()) {try {if (entry.applies && !entry.applies(requestScope)) {continue}active.push(entry)} catch (error) {logWarning("Tool applicability failed", entry.name, error)// fail closed:不能确认适用时,不向模型暴露}}return active}
requestScope 可以包含:
这让工具面成为当前请求的函数:
ToolSurface = f(assistant, model, provider, session, permissions, request)
而不是简单的全局数组。
工具之间可能存在依赖。例如 ExitWorktree 依赖 EnterWorktree;若前者可见而后者被禁用,模型会看到一个不可完成的动作。
Claude Code 路径的 resolveDisallowedTools 使用不动点算法传播禁用状态:
functionresolveDisallowedTools(toolDefinitions, userDisabled, runtimeContext) {blocked = newSet()// 第一轮:直接禁用for (tool of toolDefinitions) {if (tool.exposure === "disabled") {blocked.add(tool.name)continue}if (tool.exposure === "user" && userDisabled.has(tool.name)) {blocked.add(tool.name)continue}predicate = tool.enablePredicateif (predicate && !predicate(runtimeContext)) {blocked.add(tool.name)}}// 后续轮:禁用依赖于已禁用工具的工具changed = truewhile (changed) {changed = falsefor (tool of toolDefinitions) {if (blocked.has(tool.name)) continueif (tool.dependsOn.some(dep => blocked.has(dep))) {blocked.add(tool.name)changed = true}}}return [...blocked]}
设依赖链为:
ToolC → ToolB → ToolA
当 ToolA 被禁用时:
ToolA。ToolB。ToolC。单次遍历是否足够取决于声明顺序,不动点算法则与工具注册顺序无关。
对于工具数量为 V、依赖边为 E 的小型注册表,该实现最坏约为 O(V × (V + E))。工具图通常很小,可读性与正确性优先于复杂的拓扑优化;若未来工具图大规模增长,再改为反向依赖图上的 BFS。
大型 MCP 生态会带来数百个工具 schema。将全部工具直接放入模型上下文会造成:
Cherry Studio 使用 deferred exposition:将一部分工具从初始工具集移除,改为提供 tool_search、tool_inspect 和 tool_invoke。
初始上下文:少量常用工具+ tool_search / tool_inspect / tool_invoke+ 可用 namespace 摘要模型需要罕见工具时:tool_search → tool_inspect → tool_invoke
是否 defer 不能只比较工具数量。meta tools 自身有固定 Prompt 成本,因此必须判断净收益:
functionshouldDefer(entries, contextWindow) {autoCandidates = entries.filter(entry => entry.defer === "auto")if (autoCandidates.length < MIN_AUTO_DEFER_COUNT) {return []}estimatedSavedTokens = estimateSchemasTokens(autoCandidates)metaToolsCost = META_TOOLS_OVERHEAD_TOKENSif (estimatedSavedTokens <= metaToolsCost) {return []}returnchooseDeferredEntries(autoCandidates, contextWindow)}
这是一个典型的成本模型:
netSaving = inlineToolSchemaTokens - metaToolStaticTokens - expectedDiscoveryTokens
若 netSaving <= 0,延迟暴露会增加而不是减少成本。
审批工具必须保持 inline:
functionclassifyDeferPolicy(tool) {if (tool.needsApproval) {return"never"}return tool.defer}
如果审批工具被 defer,模型可以通过 tool_invoke 间接调用;原 SDK 的审批 gate 可能无法触发。Cherry Studio 同时在 meta tool 的执行路径再次拒绝 approval-gated 工具,形成双重防线。
结论是:
Harness 的重要职责是在连接 Agent Driver 之前构建一份一致的、可冻结的运行时配置。
Agent Session 的 settings 构建可简化为:
asyncfunctionbuildSessionSettings(session, provider, options) {assertSessionHasAgentAndWorkspace(session)awaitprepareWorkspaceDirectory(session.workspace)// 并行执行互不依赖的初始化[agentDataPath, env, workspacePlugins] = awaitPromise.all([ensureAgentDataDirectory(session.agentId),buildEnvironment(provider, session.agent),discoverWorkspacePlugins(session.workspace.path)])warmResult = awaitwarmMcpToolCaches(session.agent)permissions = awaitbuildToolPermissions(session,session.agent,agentDataPath)knowledgeScope = resolveKnowledgeBaseScope(session.agent.knowledgeBaseIds,options.selectedKnowledgeBaseIds)prompt = awaitbuildSystemPrompt({session,agent: session.agent,cwd: session.workspace.path,agentDataPath,knowledgeScope,disallowedTools: permissions.disallowedTools})mcpServers = buildMcpServers({session,agent: session.agent,knowledgeScope})return {cwd: session.workspace.path,additionalDirectories: [agentDataPath],env,plugins: workspacePlugins,systemPrompt: prompt,mcpServers,canUseTool: permissions.canUseTool,disallowedTools: permissions.disallowedTools}}
这份 settings 是 Agent Session 的 capability snapshot。其价值在于:
MCP 服务可能慢或不可用。若每次会话启动都同步 listTools,一个故障服务就会阻塞聊天。
因此热路径读取 last-known-good cache;首次冷缓存时触发后台刷新:
asyncfunctionlistToolsWithoutBlocking(serverId) {cached = cache.get(`mcp.tools.${serverId}`)if (cached is undefined) {voidrefreshToolsInBackground(serverId)}return cached ?? []}
这带来最终一致性:本次 session 在缓存尚未预热时可能看不到某些工具,后续 session 才会看到。对于 Claude Agent SDK,工具列表在会话建立时快照化,不能在同一会话中任意扩容。
settingsBuilder 对 bounded warm 超时的场景会在后台预热完成后,刷新工具元数据与 policy snapshot,避免“模型可见工具”和“审批 UI 元数据”长期不一致。
工具审批涉及 Renderer、Main、数据库和可能仍在运行的 Agent Driver。若任何一方都能直接改审批状态,竞态会迅速出现。
Cherry Studio 采用 Main 单写者:
Renderer:展示 approval card,提交用户决定Main:验证、写入权威状态、恢复对应运行时
概念状态机:
stateDiagram-v2[*] --> requestedrequested --> approvedapproved --> executingexecuting --> resolvedrequested --> denieddenied --> resolvedrequested --> abandonedabandoned --> resolved
伪代码:
asyncfunctionrespondToApproval(request) {live = approvalRegistry.get(request.approvalId)if (live.belongsToClaudeAgentSession) {// 解除 canUseTool 上等待的 promise;无需读写普通 Chat 消息行live.resolve(request.decision)return}anchor = messageService.getById(request.anchorId)part = findApprovalPart(anchor.parts, request.approvalId)if (!part) {// Renderer 可能先于持久化看到 overlay;不能盲写覆盖数据库return}updatedParts = applyApprovalDecision(anchor.parts, request.decision)messageService.update(anchor.id, { parts: updatedParts })if (allApprovalsResolved(updatedParts)) {dispatchContinueConversation(anchor)}}
关键不变量:
awaiting-approval 恢复流。这避免了 overlay 先显示、数据库后落盘时的覆盖竞态,也保证多窗口看到同一份审批状态。
Agent Session 同时需要:
它们不能混为同一目录。
cwd = session.workspace.pathadditionalDirectories = [agentDataPath]
概念校验:
functionvalidateFileOperation(targetPath, workspacePath, agentDataPath) {if (isInside(targetPath, workspacePath)) {returnallow()}if (isInside(targetPath, agentDataPath)) {returnallow()}return requireUserApproval("Path is outside the current workspace")}
这一边界使 Agent 能维护自己的长期身份和记忆,同时不能因为拥有 Agent 数据目录就任意读取用户磁盘。
目录校验不应只使用字符串前缀比较,应使用规范化、真实路径解析和 symlink 防护。否则:
/workspace-safe/../secret/workspace-safe-link → /secret
可能绕过简单的 startsWith("/workspace-safe") 检查。
Harness 还负责回答“这次 Agent 到底做了什么”。
Cherry Studio 为 AI SDK 调用建立 span tree:
chat.turn├─ ai.streamText├─ ai.streamText.step├─ ai.toolCall└─ usage / model / topic attributes
概念实现:
asyncfunctionrunObservedTurn(request) {root = trace.startSpan("chat.turn", {topicId: request.topicId,modelName: request.modelName})try {stream = await aiService.streamText(request, root.context)result = awaitpipeAndPersist(stream)root.setStatus("ok")return result} catch (error) {root.recordException(error)root.setStatus("error")throw error} finally {root.end()traceStorage.flush(request.topicId)}}
Trace 并非默认无害。开发模式下 Claude Code 的 verbose telemetry 可包含用户 Prompt、工具内容甚至原始 API body,并被写入本地 JSONL。因此必须明确:
模型可能忽略、误解或被注入内容影响。必须在工具选择和执行时再次做强制检查。
这会增加 token、延迟和选择错误。应按使用频率与净 token 收益做延迟暴露,但审批工具必须例外。
依赖链会留下半可用工具。应传播到不动点或使用反向依赖图。
多窗口、overlay 与持久化会产生竞态。审批状态必须由 Main 统一写入和恢复。
工具发现应使用 last-known-good cache 和后台刷新;产品应接受并显示最终一致性,而不是让聊天无法开始。
可观测性会变成敏感数据存储。应开发者模式门控、明确保存位置和导出策略。
Harness Engineering 的成熟标志不是“接入了很多工具”,而是系统能够清楚回答:
一句话总结:
src/main/ai/runtime/claudeCode/settingsBuilder.tssrc/main/ai/tools/adapters/claudeCode/toolConditions.ts