跳到主要内容

数据截至 (上游 commit dad6f5196773)

01 · 运行时内核:大脑出指令、引擎跑指令

本章讲什么: LobeHub 整个 agent 体系的地基是 packages/agent-runtime。这个包做了一件事——把"决定下一步干什么"和"真的去干"彻底掰成两半。读完你应该能回答:一条指令长什么样、一步循环里发生了什么、状态凭什么能被存到数据库再捞出来接着跑。

本章不讲上下文怎么拼(第 2 章)、不讲模型流怎么接(第 3 章)、不讲工具怎么真正落地执行(第 4 章)、不讲多 agent(第 6 章)。


1. 这是什么(零基础也能懂)

一句话定义: @lobechat/agent-runtime 是一个不含任何业务实现的 agent 执行框架——它定义了"指令"这种数据格式,并提供一个把指令一条条执行掉的引擎。

1.1 它要解决的问题

假设你要做一个能调用工具的 AI 助手。最朴素的写法是一个 while 循环:调模型 → 看有没有 tool_calls → 执行工具 → 把结果塞回去 → 再调模型。

这个循环写一次很容易。麻烦在于 LobeHub 要让同一个循环跑在四个完全不同的地方:

跑在哪工具怎么执行消息存哪
浏览器(纯客户端)浏览器里的插件 iframeIndexedDB
服务端(云端会话)服务器进程 + 数据库事务PostgreSQL
云沙箱远程容器服务端代管
本地 CLI / 桌面端本机文件系统、shell本地库

如果把"决策"和"执行"写在一个函数里,这四份代码就得复制四遍。LobeHub 的解法是切一刀:

  • 决策产出的是一段可序列化的 JSON(叫 instruction,指令)。
  • 执行是一张可替换的函数表(叫 executors,执行器)。

于是"大脑"只有一份,"手脚"按环境换一套。

1.2 一句话直觉

把 Agent 当成下棋的人,AgentRuntime 当成棋盘和裁判。人只说"车二平五"这句话(指令),真正把棋子挪过去、判合不合规、记时的是棋盘。换个场地(线上/线下)只换棋盘,下棋的人不用重学。

这个类比只用于建立直觉,下文一律用 Agent / AgentRuntime / instruction 这三个术语,一词一义。

1.3 用起来什么样

包里自带一个能真跑的最小例子 packages/agent-runtime/examples/tools-calling.ts(bun run simple 启动)。它的骨架就是外层驱动循环:

// packages/agent-runtime/examples/tools-calling.ts:243-298,示意精简
const agent = new SimpleAgent(); // 大脑:自带 modelRuntime 和 tools
const runtime = new AgentRuntime(agent); // 引擎

let state = AgentRuntime.createInitialState({
maxSteps: 10,
messages: [{ content: '现在几点?顺便算一下 15 * 8 + 7', role: 'user' }],
});

let nextContext; // 第一次是 undefined,引擎自己推断出 user_input 相位
while (state.status !== 'done' && state.status !== 'error') {
const result = await runtime.step(state, nextContext); // 走一步
for (const event of result.events) render(event); // 事件流:渲染 / 落库 / 打点
state = result.newState; // 新状态
nextContext = result.nextContext; // 引擎告诉大脑"下一步你面对的是什么相位"
}

三点先记住,后面每一节都在展开它们:

  1. while 循环在包外面。 AgentRuntime 只提供 step()(单步),谁来反复调它、什么时候停,由执行面自己决定。
  2. step() 是纯函数式的:(state, context),返回 { events, newState, nextContext },不改传进来的 state
  3. nextContext 是引擎回传给大脑的接力棒,它带着 phase 字段,大脑靠它判断"我现在处在循环的哪一环"。

2. 顶层全景(它大概怎么转)

2.1 大脑与引擎的职责切分

┌──────────────────────── 一次 step() ────────────────────────┐
│ │
│ AgentState ──┐ │
│ (可序列化护照) │ │
│ ▼ │
│ ┌───────────────┐ 指令(纯 JSON) ┌──────────────┐ │
│ │ Agent「大脑」 │ ─────────────────►│ AgentRuntime │ │
│ │ runner() │ │ 「引擎」 │ │
│ │ 只做判断 │◄───────────────── │ executors │ │
│ └───────────────┘ nextContext └──────┬───────┘ │
│ ▲ (带 phase) │ │
│ │ ▼ │
│ └──────── newState ◄────── events(流式出口) │
│ │
└─────────────────────────────────────────────────────────────┘

怎么读这张图:左边只做决定、右边只做执行;两者之间来回传的东西全是可序列化数据(指令、context、state),没有闭包、没有回调、没有对象引用。

2.2 部件一句话职责

部件干什么在哪个文件
Agent接口。核心方法 runner(context, state) 返回一条或一组指令,完全无状态packages/agent-runtime/src/types/instruction.ts:74-118
AgentRuntime引擎。step() 拿指令、找执行器、跑、汇总事件和新状态packages/agent-runtime/src/core/runtime.ts:27
AgentInstruction指令联合类型,14 个成员,大脑与引擎之间唯一的"话术"packages/agent-runtime/src/types/instruction.ts:396-413
AgentState可持久化护照:消息、状态机、用量、成本、工具集快照……packages/agent-runtime/src/types/state.ts:21-167
AgentRuntimeContext单步上下文,核心是 phase 字段packages/agent-runtime/src/types/instruction.ts:16-68
GeneralChatAgent默认大脑实现:一台按 phase 分支的相位状态机packages/agent-runtime/src/agents/GeneralChatAgent.ts:47
InterventionChecker审批判定器:一次工具调用要不要拦下来问人packages/agent-runtime/src/core/InterventionChecker.ts:30
UsageCounter纯累加器:token、调用次数、费用packages/agent-runtime/src/core/UsageCounter.ts:10

2.3 主线走一遍(不进代码)

一条用户消息在这个内核里的旅程:

  1. 外层把 AgentState.messages 填上用户消息,调 runtime.step(state)
  2. 引擎发现最后一条消息是 user,构造出 phase: 'user_input' 的 context(runtime.ts:919-940 createInitialContext)。
  3. 大脑收到 user_input,返回 { type: 'call_llm', payload: {...} }
  4. 引擎找到 call_llm 执行器,流式跑模型,把每个 chunk 变成 llm_stream 事件,最后回传 phase: 'llm_result' 的 nextContext。
  5. 大脑收到 llm_result,看有没有 tool_calls:有就派工具、没有就 finish
  6. 外层循环拿新 state 再调 step(),直到 statusdone / error,或者被"阻塞态"挡住。

3. 指令协议:大脑与引擎之间唯一的话术

3.1 为什么指令必须是纯数据

只要指令是 JSON,它就能:跨进程传(客户端算好丢给服务端跑)、落库重放(审计、tracing)、被 mock(测试大脑时不用真跑模型)。这是整个包最重要的一条设计约束——AgentInstruction 的注释原话就是 "A serializable instruction object"(instruction.ts:392-395)。

3.2 全部 14 种指令

AgentInstruction 是个联合类型(instruction.ts:396-413),按语义分五组:

指令 type干什么引擎内置执行器?
LLMcall_llm调一次模型,流式吐 chunkruntime.ts:421
工具call_tool执行单个工具调用runtime.ts:506
工具call_tools_batch并发执行一批工具调用⚠️ 走兜底路径 executeToolsBatch(runtime.ts:720)
工具resolve_blocked_tools给被安全策略拦下的工具补一条失败的 tool 消息,让对话能继续runtime.ts:576
工具resolve_aborted_tools给用户中途取消的工具补终结消息❌ 需注入
子 agentexec_sub_agent / exec_sub_agents在服务端派生一个/一批子 agent❌ 需注入
子 agentexec_client_sub_agent / exec_client_sub_agents在桌面端本地派生子 agent(要用本机文件/shell 时必须走这条)❌ 需注入
人在环request_human_prompt向人要一段文本输入runtime.ts:641
人在环request_human_select向人要一次(多)选runtime.ts:667
人在环request_human_approve挂起,等人批准这批工具调用runtime.ts:616
控制compress_context上下文超阈值,先压缩再继续❌ 需注入
控制finish收敛,带 FinishReasonruntime.ts:695

这张表最该读出来的信息:内置引擎只能撑起一个"玩具聊天 agent"。 14 种指令里内置只覆盖 7 种,剩下 6 种(resolve_aborted_tools、四种子 agent、compress_context)在裸 AgentRuntime 上会直接抛错:

// packages/agent-runtime/src/core/runtime.ts:183-186
const executor = this.executors[instruction.type as keyof typeof this.executors];
if (!executor) {
throw new Error(`No executor found for instruction type: ${instruction.type}`);
}

真正的能力全靠执行面注入执行器补齐——这正是 第 5 章 的主题。

3.3 相位(phase):引擎告诉大脑"你现在在哪一环"

AgentRuntimeContext.phase 是一个 12 值的字符串联合(instruction.ts:36-48)。大脑的 runner 本质上就是一个 switch (context.phase)

phase谁产生的大脑通常怎么回应
init引擎兜底(最后一条不是 user 消息时)user_input
user_input引擎兜底(最后一条是 user 消息时)call_llm(或先 compress_context)
llm_resultcall_llm 执行器派工具 / finish
tool_resultcall_tool 执行器回灌结果再 call_llm
tools_batch_result批量工具 / resolve_blocked_tools同上
sub_agent_result / sub_agents_batch_result注入的子 agent 执行器回灌结果再 call_llm
human_approved_tool外层调 approveToolCall()不经过大脑,引擎短路直接执行(见 §4.3)
human_response外层(人给了输入)GeneralChatAgent 未处理,落到 default 分支 → finish
human_abort外层(用户点了停止)收尾未决工具 / finish
compression_result注入的压缩执行器用压缩后的消息 call_llm
error引擎的成本告警分支finish(reason error_recovery)

注意 human_response 这一行:类型里有它,但 GeneralChatAgent.runnerswitch 没有对应 case(GeneralChatAgent.ts:579-963),会掉进 default 直接结束。这是代码里能读出的事实,不是推断。


4. 引擎的单步循环:AgentRuntime.step()

这是整个包的心脏,只有约 150 行(runtime.ts:82-231)。

4.1 一步之内发生了什么

step(state, context)


① clone state,stepCount += 1


② 超 maxSteps? ──是──► forceFinish = true(不报错,继续往下走)
│否

③ phase 是 human_approved_tool? ──是──► 跳过大脑,直接造一条 call_tool
│否

④ await agent.runner(context, state) ← 唯一一次问大脑


⑤ 归一化:单条包成数组 + 旧格式 call_tools_batch 转新格式


⑥ for 每条指令 → 查 executors → 执行 → 累积 events / 更新 state
│ │
│ status 变阻塞态 ──► break

⑦ 有 finish 指令则把 stepCount 减回去;阻塞态则清空 nextContext

怎么读:从上到下是严格顺序,只有 ⑥ 是循环。整步只问一次大脑,但可能执行多条指令。

4.2 步数上限的降级:超限不报错,而是"温柔收尾"

大多数 agent 框架撞到 maxSteps 会直接抛错或硬停,结果是用户看到一个半截的回答。LobeHub 的处理更细致:

// packages/agent-runtime/src/core/runtime.ts:93-102
if (newState.maxSteps && newState.stepCount > newState.maxSteps) {
if (newState.forceFinish) {
// 已经在 forceFinish 流程里,跳过检查继续执行
} else {
newState.forceFinish = true;
}
}

这个布尔量本身什么都不做,它的威力在于被下游三处读到:

谁读 forceFinish做了什么位置
上下文引擎的工具装配把这一步的工具全部剥掉(deactivatedToolIds = ['*'])packages/context-engine/src/engine/tools/buildStepToolDelta.ts:64-66
上下文引擎的消息装配追加一条 system 消息:"你已达到最大步数上限,请总结进度并给出最终回复,不要再调用任何工具"packages/context-engine/src/providers/ForceFinishSummaryInjector.ts:46-50
大脑的压缩预算既然工具不发了,压缩判定就不该把工具定义算进 token 预算GeneralChatAgent.ts:518:589(tools: state.forceFinish ? undefined : ...)

链路闭合在大脑的 llm_result 分支:模型这次没工具可调,只能吐纯文本,于是走到"无 tool_calls → finish",终止原因被标成 max_steps_completed 而不是错误(GeneralChatAgent.ts:712)。

这就是"降级"二字的含义:超限不是失败,是把 agent 从"能动手"切成"只能说话",让它自己把话说完。 注意剥工具和注 prompt 都发生在注入的执行器里,裸引擎只负责竖起那面旗子。

4.3 human_approved_tool 的短路

人点了"批准"之后,不需要再问大脑一遍——它上一轮已经决定过要调这个工具了。引擎直接凭 context 里的 payload 拼出指令:

// packages/agent-runtime/src/core/runtime.ts:111-127,示意精简
if (runtimeContext.phase === 'human_approved_tool') {
const { approvedToolCall, parentMessageId, skipCreateToolMessage } = runtimeContext.payload;
rawInstructions = {
payload: { parentMessageId, skipCreateToolMessage, toolCalling: approvedToolCall },
type: 'call_tool',
};
} else {
rawInstructions = await this.agent.runner(runtimeContext, newState); // 正常路径
}

配套的便捷入口是 runtime.approveToolCall(state, toolCall)(runtime.ts:236-248),它就是造好这个 context 再调 step()

skipCreateToolMessage 这个标志值得留意:批准场景下工具消息在挂起时已经存在了(状态是 pending),执行器要做的是"更新它",而不是"再插一条"(instruction.ts:249-255)。

4.4 旧格式 call_tools_batch 的归一化

早期 call_tools_batch 的 payload 直接是一个 OpenAI 风格的 ToolsCalling[] 数组;新格式是 { parentMessageId, toolsCalling } 对象。引擎在执行前做一次自动搬运:

// packages/agent-runtime/src/core/runtime.ts:136-159,示意精简
if (instruction.type === 'call_tools_batch' && Array.isArray(instruction.payload)) {
const toolsCalling = instruction.payload.map((tc) => ({
apiName: tc.function.name, // OpenAI 的 function.name
arguments: tc.function.arguments,
id: tc.id,
identifier: tc.function.name, // 旧格式没有插件 identifier,只能用同一个名字顶上
thoughtSignature: tc.thoughtSignature, // Gemini 3.x 的思考签名,必须原样带回
type: 'default',
}));
return { payload: { parentMessageId: '', toolsCalling }, type: 'call_tools_batch' };
}

判据是 Array.isArray(instruction.payload)——数组即旧格式。转换里有个诚实的妥协:identifier 只能填成和 apiName 一样的值,因为旧格式压根没带插件标识。

4.5 多指令顺序执行与"阻塞态刹车"

一次 runner 可以返回一组指令。引擎按数组顺序串行执行,每条执行完检查一次状态:

// packages/agent-runtime/src/core/runtime.ts:202-205
// Stop execution if blocked
if (isBlockedStatus(currentState.status)) {
break;
}

isBlockedStatus 的定义很干净(packages/agent-runtime/src/utils/status.ts:11-19):

状态归类含义
waiting_for_humanparked(泊车)等人批准 / 等人输入
waiting_for_async_toolparked(泊车)等异步工具或子 agent 结果
interruptedblocked用户主动取消
done / error都不是终态,走各自的收尾逻辑

"泊车"和"终态"要分开,是因为调度器要把泊车中的 operation 继续当作活着的任务,不能盖 completedAt 时间戳——注释里把这一点说得很明白(status.ts:3-10)。

刹车还有第二层:阻塞时把 nextContext 清成 undefined,外层 while 就自然停转:

// packages/agent-runtime/src/core/runtime.ts:223
nextContext: isBlockedStatus(currentState.status) ? undefined : finalNextContext,

4.6 stepCount 的会计约定

step() 一进来就 +1(runtime.ts:89)。但 finish 并不是一次真正的执行,所以结尾要减回去:

// packages/agent-runtime/src/core/runtime.ts:212-215
// A 'finish' instruction is not a real execution step, undo the +1 from the top of step()
if (hasFinishInstruction) {
currentState.stepCount = Math.max(currentState.stepCount - 1, 0);
}

测试里明确锁住了这个行为:连着两步、第二步是 finish,stepCount 停在 1 不变(packages/agent-runtime/src/core/__tests__/runtime.test.ts:626-653)。

4.7 批量工具:同一个基态上并发跑

executeToolsBatch(runtime.ts:720-747)的做法有点反直觉——每个工具都从同一份 baseState 的克隆出发:

// packages/agent-runtime/src/core/runtime.ts:732-741,示意精简
const results = await pMap(instruction.payload.toolsCalling, (toolCalling) =>
this.executors.call_tool(
{ payload: { parentMessageId, toolCalling }, type: 'call_tool' },
structuredClone(baseState), // 每个工具都从同一个基态起步
context,
),
);

因为并发的分支各自持有一份 state 副本,合并时就必须去重。mergeToolResults(runtime.ts:752-836)先记下基态里已有的 tool_call_id 集合,只把新增的 tool 消息并进来(runtime.ts:769-779),再逐项累加 usage 和 cost。

两个可以直接读出来的细节:

  • 批量路径调的是 this.executors.call_tool,所以覆写 call_tool 会连带改变批量行为
  • pMap 调用没有传 concurrency 选项(runtime.ts:732),即并发不设上限。

5. executors 的三级覆盖:同一个引擎,四套手脚

5.1 优先级从低到高

构造函数里一个展开运算符的顺序,就是全部规则(runtime.ts:40-52):

内置执行器 (7 种)
│ 被覆盖

config.executors ← new AgentRuntime(agent, { executors })
│ 被覆盖

agent.executors ← Agent 自带,最高优先级
// packages/agent-runtime/src/core/runtime.ts:40-52
this.executors = {
call_llm: this.createCallLLMExecutor(),
call_tool: this.createCallToolExecutor(),
finish: this.createFinishExecutor(),
request_human_approve: this.createHumanApproveExecutor(),
request_human_prompt: this.createHumanPromptExecutor(),
request_human_select: this.createHumanSelectExecutor(),
resolve_blocked_tools: this.createResolveBlockedToolsExecutor(),
// Config executors override built-in
...config.executors,
// Agent provided executors have highest priority
...(agent.executors as any),
};

5.2 实践中真正用到的是中间那层

生产代码里两个执行面都走 config.executors,谁也没用 agent.executors:

执行面注入点注入的是什么
服务端apps/server/src/services/agentRuntime/AgentRuntimeService.ts:3006createRuntimeExecutors(executorContext),带数据库、流管理器、hook 派发器
浏览器src/store/chat/slices/agentRun/actions/transports/client/streamingExecutor.ts:692createAgentExecutors({...}),带 zustand store 和 toolsEngine

两处传的 agent 都是同一个 GeneralChatAgent大脑一份,手脚两套——这就是切分线的兑现方式。细节见 第 5 章

5.3 内置执行器都做了什么(挑三个)

call_llm(runtime.ts:421-503):先发 llm_start 事件,再 for await 消费 agent.modelRuntime(payload) 的流,每个 chunk 发一个 llm_stream 事件,累积文本和 tool_calls,最后发 llm_result 并造出 phase: 'llm_result' 的 nextContext。它对模型协议的要求极低——只要是个吐 { content?, tool_calls? } 的 async iterable 就行(instruction.ts:103)。

call_tool(runtime.ts:506-573):从 agent.tools 这张简单注册表里按名字取函数,JSON.parse 参数,执行,把结果 JSON.stringify 成一条 role: 'tool' 消息推进 messages。这是给 demo 用的:真实执行面会整个换掉它,以支持 MCP、插件 iframe、审批状态回写等等。

resolve_blocked_tools(runtime.ts:576-613):被安全策略拦下的工具不能"没有下文"——OpenAI 协议要求每个 tool_call 都必须有对应的 tool 消息,否则下一轮请求直接报错。所以它给每个被拦的调用补一条固定内容:

// packages/agent-runtime/src/core/runtime.ts:583-586
const result = {
content: 'Blocked by security/privacy.',
success: false,
};

然后把相位设成 tools_batch_result 让对话继续。这是"安全拦截"和"协议完整性"之间的胶水层。


6. AgentState:一本可持久化的护照

6.1 为什么叫护照

源码注释直接给了定义:"This is the 'passport' that can be persisted and transferred"(packages/agent-runtime/src/types/state.ts:18-20)。

一个纯 JSON 的状态对象,意味着三件事同时成立:

  1. 能存。 服务端把它整个写进 agentOperations 表,进程重启后捞出来接着跑。
  2. 能迁。 浏览器里跑到一半的会话,可以把 state 提交给服务端继续——因为大脑无状态,换个引擎接手不影响判断。
  3. 能停。 等人批准可能等几个小时,期间不需要任何进程常驻内存。

这条设计是 第 5 章"四个执行面"能成立的前提。反过来说:任何写进大脑实例字段的状态都会破坏这个性质——GeneralChatAgent 因此一个实例字段都不存(除了只读的 config)。

6.2 字段分组速查

分组字段作用
会话核心messages完整消息数组,类型故意放成 any[],把消息形态的定义权交给上层
状态机status7 个值:idle / running / waiting_for_human / waiting_for_async_tool / done / error / interrupted
执行计数stepCountmaxStepsforceFinish步数护栏三件套(见 §4.2)
计费usagecostcostLimit用量、已花费、上限与超限动作
工具装配toolstoolManifestMapoperationToolSettoolSourceMaptoolExecutorMap本轮工具定义、清单、operation 级不可变快照、以及"这个工具该由谁执行"的路由表
人在环pendingToolsCallingpendingHumanPromptpendingHumanSelect挂起时把"在等什么"落进状态
安全securityBlacklistuserInterventionConfig黑名单规则、用户审批模式
中断interruption{ reason, interruptedAt, canResume },决定还能不能 resume()
模型modelRuntimeConfig{ model, provider, compressionModel? },指令没指定时的兜底
增量记录activatedStepToolsactivatedStepSkills步级动态激活的工具/技能的累积记录

几个值得单独点名的:

  • operationToolSet 标注为 "immutable after creation"(state.ts:92-93)。一次 operation 开始时把可用工具集冻住,后续步骤不会因为用户中途改配置而漂移。
  • toolExecutorMap / toolSourceMap 是把"路由决策"存进状态,而不是靠运行时环境判断——这样迁移到另一个执行面时路由信息跟着走。
  • ToolsCalling.thoughtSignature(state.ts:159-172)是个很具体的坑:Gemini 3.x 的思考签名必须在后续请求里原样回传,否则会收到一个误导性的 "ordering" 400 错误。注释把这个教训记下来了。

6.3 状态是怎么被创建的

AgentRuntime.createInitialState()(runtime.ts:398-416)是个静态工厂:先铺一层默认值(空消息、status: 'idle'stepCount: 0、零值 usage / cost),再用传入的 partial 覆盖。

留意展开顺序:用户传的值在后面,能覆盖任何默认值,包括 stepCount——测试里就直接构造了 stepCount: 5 的状态(runtime.test.ts:534-541)。这对"从数据库恢复一个跑到一半的 operation"是必需的。


7. GeneralChatAgent:默认大脑的相位状态机

7.1 全景

runnerGeneralChatAgent.ts:569 开始,结构是"一个前置守卫 + 一个 switch (context.phase)":

runner(context, state)


status === 'interrupted'? ──是──► handleAbort():收尾未决工具 / finish
│否

switch (context.phase)
├── init / user_input ──────► 先判压缩 → compress_context 或 call_llm
├── llm_result ─────────────► 有工具:审批分流;无工具:finish
├── tool_result ────────────► 子 agent 分流 / 未决检查 / 回灌 call_llm
├── tools_batch_result ─────► 未决检查 / 回灌 call_llm
├── sub_agent(s)_result ────► 回灌 call_llm(批量结果还会补一条虚拟 user 消息)
├── compression_result ─────► 用压缩后的消息 call_llm
├── human_abort / error ────► finish
└── default ────────────────► finish(reason: agent_decision)

怎么读:前置守卫在所有分支之前,保证"用户取消"在任何相位都有一致行为(GeneralChatAgent.ts:575-577)。

7.2 user_input:先算账,再调模型

新手最容易漏掉的一环:调模型之前先判断上下文会不会爆

// packages/agent-runtime/src/agents/GeneralChatAgent.ts:459-472,示意精简
if (compressionEnabled) {
const compressionCheck = shouldCompress(state.messages, compressionOptions);
if (compressionCheck.needsCompression) {
return {
payload: {
currentTokenCount: compressionCheck.currentTokenCount,
existingSummary: this.findExistingSummary(state.messages), // 增量压缩:带上旧摘要
messages: state.messages,
},
type: 'compress_context',
};
}
}

三个细节:

  1. 压缩默认开着(?? true,GeneralChatAgent.ts:583)。
  2. forceFinish 时不把工具算进预算:既然这一步工具会被剥掉,再为工具定义的 token 烧一次总结就是浪费(GeneralChatAgent.ts:589 的注释把这个推理写清楚了)。
  3. 增量压缩:findExistingSummary(:352-366)会从消息里翻出上一次的压缩摘要带进 payload,避免每次都从零总结。

阈值判定在 shouldCompress(packages/agent-runtime/src/utils/tokenCounter.ts:68-84),细节见 第 2 章

7.3 llm_result:同时下发"立即执行"和"等人批准"

这是整个大脑最有意思的一段。模型一次可能返回 5 个工具调用,其中 3 个无害(读文件)、2 个危险(删目录)。朴素做法是整批挂起等人,代价是无害的那 3 个也白等。

GeneralChatAgent 的做法是拆两拨、一次下发:

toolsCalling (模型返回的一批)


checkInterventionNeeded() ──► 返回二元组 [需要审批的, 可直接执行的]

├── 可直接执行的 (>1 条) ──► push { type: 'call_tools_batch' }
│ 可直接执行的 (=1 条) ──► push { type: 'call_tool' }

└── 需要审批的 ──┬── 普通模式 ──► push { type: 'request_human_approve' }
└── headless ──► push { type: 'resolve_blocked_tools' }
(无人可问,直接给失败结果让对话继续)

怎么读:两个分支都往同一个 instructions 数组里 push,顺序固定——先执行,后挂起

// packages/agent-runtime/src/agents/GeneralChatAgent.ts:493-542,示意精简
const [toolsNeedingIntervention, toolsToExecute] = await this.checkInterventionNeeded(
toolsCalling,
state,
);
const instructions: AgentInstruction[] = [];

if (toolsToExecute.length > 0) {
instructions.push(toolsToExecute.length > 1 ? batchInstruction : singleToolInstruction);
}
if (toolsNeedingIntervention.length > 0) {
instructions.push(
state.userInterventionConfig?.approvalMode === 'headless'
? blockedToolsInstruction
: approveInstruction,
);
}
return instructions; // 一次返回两条指令

顺序为什么必须是这个: 回到 §4.5 的刹车逻辑——引擎串行执行时,call_tools_batch 跑完 status 还是 running,继续执行第二条;request_human_approve 把 status 设成 waiting_for_human,循环 break如果顺序反过来,安全工具就永远等不到执行。 这条时序依赖是隐式的,读代码时要自己接上。

顺带记一个诊断细节:当模型返回了 tool_calls 但一个都没解析成功(例如工具名缺少 ____ 分隔符),finishreasonDetail 会写明"LLM returned N unresolvable tool_calls: ..."(GeneralChatAgent.ts:703-724),好让监控区分"真的没工具可调"和"工具名坏了"。

7.4 未决工具的"当前轮"作用域

tool_result / tools_batch_result 两个相位都要检查"还有没有工具在等批准"。天真的写法是扫全量消息找 pluginIntervention.status === 'pending',但历史消息里可能残留上一轮被用户放弃的审批记录——它会劫持之后每一轮,把循环永久停在 waiting_for_human

修法是把检查限定在"当前这一轮":

// packages/agent-runtime/src/agents/GeneralChatAgent.ts:328-346,示意精简
// 1) 从后往前找最近一条"发起过工具调用"的 assistant 消息
let currentAssistantId;
for (let i = state.messages.length - 1; i >= 0; i--) {
const m = state.messages[i];
if (m.role === 'assistant' && (m.tool_calls?.length > 0 || m.tools?.length > 0)) {
currentAssistantId = m.id;
break;
}
}
// 2) 只认 parentId 指向它的 pending 工具消息
return state.messages.filter(
(m) =>
m.role === 'tool' && m.pluginIntervention?.status === 'pending' && m.parentId === currentAssistantId,
);

重点看 m.parentId === currentAssistantId 这个条件——它就是"作用域"本身。函数上方 19 行的注释把 bug 现场描述得非常完整(:312-327),是这份代码库里少见的"把踩坑经过写进注释"的范例。


8. 人在环与安全闸

8.1 一次工具调用要过几道关

checkInterventionNeeded(GeneralChatAgent.ts:159-301)是一条七级流水线。先看整体形状:

一个 toolCalling


① 全局审计器(默认 = 安全黑名单) ── 命中且 policy='always' ─────► 必须审批(不可绕过)
│未命中 / 可绕过

② 取工具的 humanIntervention 配置(API 级 > 工具级)


③ dynamic 配置? ──是──► 跑审计器函数 → never 放行 / 否则审批
│否

④ 静态规则里有 policy='always' 且参数匹配? ──是──► 必须审批(压过 auto-run)
│否

⑤ approvalMode = headless / auto-run? ──是──► 放行
│否

⑥ 工具不在 toolManifestMap 里? ──是──► 审批(未知工具一律拦)
│否

⑦ allow-list 命中 / manual 模式跑 InterventionChecker ──► 放行 或 审批

怎么读:从上往下,命中即停(每个分支都以 continue 结束)。越靠上的关卡优先级越高。

8.2 配置的三个层次

层次谁定的字段覆盖关系
用户全局用户设置state.userInterventionConfig.approvalMode四档:manual(默认)/ allow-list / auto-run / headless
工具级插件清单manifest.humanIntervention被 API 级覆盖
API 级插件清单里单个 APIapi.humanIntervention最高,一行代码说清
// packages/agent-runtime/src/agents/GeneralChatAgent.ts:62-65
const api = manifest.api?.find((a: any) => a.name === apiName);
// API-level config takes precedence over tool-level config
return api?.humanIntervention ?? manifest.humanIntervention;

同一个插件里,readFile 可以配成永不打扰,deleteFile 配成每次必问。

8.3 参数级匹配:matchesAlwaysPolicy

策略不只看"哪个工具",还能看"参数长什么样"。matchesAlwaysPolicy(GeneralChatAgent.ts:110-133)对规则数组做 OR、对单条规则的多个参数匹配器做 AND:

// packages/agent-runtime/src/agents/GeneralChatAgent.ts:84-98,示意精简
return config.some((rule) => {
if (rule.policy !== 'always') return false;
if (!rule.match) return true; // 没有匹配条件 = 无条件命中
return Object.entries(rule.match).every(([paramName, matcher]) => {
const paramValue = toolArgs[paramName];
if (paramValue === undefined) return false; // 参数缺失 = 不匹配
if (typeof matcher === 'string') {
return String(paramValue).includes(matcher) || matcher.includes('*');
}
return true;
});
});

更完整的匹配器实现在 InterventionChecker.matchesArgument(packages/agent-runtime/src/core/InterventionChecker.ts:160-188),支持四种类型:

type语义例子
exact全等rm -rf /
prefix前缀git push
wildcard* 通配,另外支持 冒号:* 这种命令前缀写法git add:*
regex正则rm\\s+-rf\\s+[~./]\\s*$

matchPattern(:197-212)里有个专门的分支处理冒号写法:"git add:*" 会匹配 git add: 开头或恰好等于 git add 的值。

8.4 dynamic:把判定权交给一个函数

有些判断没法写成静态规则(比如"这条 SQL 会不会动生产库")。dynamic 配置把决定权交给一个注册过的审计器:

// packages/agent-runtime/src/agents/GeneralChatAgent.ts:110-117,示意精简
const { dynamic } = config;
const resolver = this.config.dynamicInterventionAudits?.[dynamic.type];
if (!resolver) return Promise.resolve(dynamic.default ?? 'never'); // 没注册就走默认
return Promise.resolve(resolver(toolArgs, metadata)).then((shouldIntervene) =>
shouldIntervene ? (dynamic.policy ?? 'always') : (dynamic.default ?? 'never'),
);

审计器签名是 (toolArgs, metadata) => boolean | Promise<boolean>,返回 true 表示"该拦"。

8.5 安全黑名单:压过一切用户设置的底线

DEFAULT_SECURITY_BLACKLIST(packages/agent-runtime/src/audit/defaultSecurityBlacklist.ts:12-359)是一份 34 条正则规则的清单,分六类:

分类例子规则(description 是 i18n key)参数
文件系统rmHomeDirrmRootDirrmForceRecursivecommand
系统配置etcPasswdsudoerscommand
危险命令forkBombddDiskWriteformatPartitioncommand
网络与远程disableFirewallsshConfigcommand
包管理removeSystemPackagescommand
凭据读取browserCredentialsgcpCredentialscommand / path

规则分两档 policy:不写 = always(任何模式下都拦,包括 auto-run),写 policy: 'required' = 可被自动化流程绕过。头部注释把这条区分写明了(defaultSecurityBlacklist.ts:6-7)。

黑名单是通过"全局审计器"接进流水线的。默认注册了两个,顺序刻意排成"不可绕过的在前":

// packages/agent-runtime/src/audit/globalAudit.ts:5-8
export const createDefaultGlobalAudits = (): GlobalInterventionAuditConfig[] => [
createSecurityBlacklistGlobalAudit(), // policy 默认 'always'
createSecurityBlacklistGlobalAudit('required'),
];

createSecurityBlacklistAudit(packages/agent-runtime/src/audit/createSecurityBlacklistAudit.ts:17-29)做的事很简单:按 policy 过滤出对应那一档规则,交给 InterventionChecker.checkSecurityBlacklist 匹配。因为流水线第一关是"命中即停"(GeneralChatAgent.ts:196-208),always 档先跑,就保证了它不会被 required 档的宽松处理抢先。

黑名单在另一条路径上也生效:manual 模式最后落到 InterventionChecker.shouldIntervene,那里同样把黑名单放在最前面:

// packages/agent-runtime/src/core/InterventionChecker.ts:70-75
// CRITICAL: Check security blacklist first - this overrides ALL other settings
const securityCheck = this.checkSecurityBlacklist(securityBlacklist, toolArgs);
if (securityCheck.blocked) {
// Security blacklist always requires intervention, even in auto-run mode
return 'required';
}

同一份规则、两条路径都设卡——这是刻意的冗余。

8.6 两条容易忽略的保守默认

  • 未知工具一律拦。 工具不在 toolManifestMap 里就要求审批,并打一条带全部 key 的 warn 日志(GeneralChatAgent.ts:266-274)。仅对 manual / allow-list 生效——选了 auto-run 的用户被视为自担风险。
  • 没有规则匹配上时默认拦。 shouldIntervene 遍历完规则数组没命中,返回 'required' 而不是 'never'(InterventionChecker.ts:92-93,注释:"default to require for safety")。

8.7 挂起时状态里留下了什么

request_human_approve 执行器(runtime.ts:616-638)做三件事:status 设成 waiting_for_human、把待批工具写进 state.pendingToolsCalling、发一个 human_approve_required 事件。前两件保证状态存进数据库后还原得回来,第三件负责通知 UI。


9. 成本与步数护栏

9.1 用量与费用的记账口径

Usage(packages/agent-runtime/src/types/usage.ts:18-59)分三块,Cost(:64-109)分两块:

结构分块记什么
UsagellmapiCallsprocessingTimeMstokens.{input,output,total}
UsagetoolstotalCallstotalTimeMsbyTool[](逐工具的调用数/耗时/错误数)
UsagehumanInteraction审批/输入/选择的请求次数与总等待时长
Costllm.byModel[]provider/model 聚合的花费 + 完整 ModelUsage 明细
Costtools.byTool[]按工具聚合的花费

humanInteraction 记等待时长这一项值得留意:它说明这个内核从一开始就把"人"当成一种有成本的资源来度量。

UsageCounter(packages/agent-runtime/src/core/UsageCounter.ts:10)是纯累加器,两个静态方法:

  • accumulateLLM(:123-176):累加 token、apiCalls += 1,并按 provider/model 找到或新建条目,用 mergeModelUsage(:66-111)把 18 个细分 token 字段(含缓存命中、推理、图像/音频/视频 token)逐项相加。
  • accumulateTool(:189-249):按工具名累加调用数、耗时,失败时 errors += 1

两个方法都 structuredClone 输入再改,不改原对象。

9.2 成本上限的三种反应

CostLimit(usage.ts:114-125)带一个 onExceeded 字段。引擎在每次 LLM 调用后(runtime.ts:481-484)和每次工具调用后(:554-557)检查一次,超了就进 handleCostLimitExceeded(:841-901):

onExceeded行为状态变成
stopdone 事件,reason 为 cost_limit_exceededdone
interruptinterrupt(),记 canResume: true 和超额元数据interrupted
warn(default 分支)只发一个 error 类型的警告事件,继续跑不变,相位转 error

注意 warn 分支产生的 nextContext 相位是 'error',而 GeneralChatAgenterror 分支会返回 finish(GeneralChatAgent.ts:945-953)——所以用默认大脑时,warn 实际也会收敛,只是收敛路径不同

9.3 成本计算的钩子挂在大脑上

引擎自己不知道任何模型的价格。它只在执行器里检查 agent.calculateUsage / agent.calculateCost 存不存在,存在才调(runtime.ts:464-479)。定价知识因此留在上层,内核保持零业务耦合。

9.4 步数护栏的边界

引擎的 maxSteps 检查不会终止循环,只竖 forceFinish 旗子(见 §4.2)。真正的硬性上限得由外层驱动循环自己保证——服务端的注释就明说了这一点:"maxSteps is handled by runtime.step() which sets forceFinish"(apps/server/src/services/agentRuntime/AgentRuntimeService.ts:3069),同时允许 forceFinish 生效后再多跑一步(AgentRuntimeService.test.ts:1655-1659)。

9.5 步级上下文:computeStepContext

computeStepContext(packages/agent-runtime/src/utils/stepContextComputer.ts:40-52)把四项动态数据拼成 stepContext,由外层在每次 step() 前算好塞进 context:activatedSkillsactivatedToolIdshasQueuedMessagestodos

它只是个组装函数——数据本身由 UI 层的 selector 算(注释 :35-37 说明了这个分工,目的是让 selector 逻辑能被 UI 复用)。

hasQueuedMessages 是里面最有性格的一项:用户在 agent 跑的过程中又发了消息,大脑读到它就提前收尾,让排队的消息作为一次全新的 operation 带完整上下文重跑(GeneralChatAgent.ts:778-780:682-684),终止原因是 queued_message_interrupt这是一次"软打断"——不是取消,是让路。


10. 事件出口与 hooks

10.1 事件:引擎对外唯一的输出通道

AgentEvent(packages/agent-runtime/src/types/event.ts:127-151)是 15 个成员的联合类型,按用途分组:

事件典型消费者
初始化init
LLM 流式llm_startllm_streamllm_resultUI 打字机效果
工具tool_pendingtool_result工具卡片渲染
终态done(带完整 finalStateFinishReason)、error收尾、落库
人在环human_approve_requiredhuman_prompt_requiredhuman_select_required弹审批面板
中断恢复interruptedresumed状态提示
上下文压缩compression_completecompression_error消息树折叠

FinishReason(event.ts:60-71)是 11 个值的枚举,把"为什么停"标准化了:completed / user_requested / user_aborted / max_steps_exceeded / max_steps_completed / cost_limit_exceeded / timeout / agent_decision / queued_message_interrupt / error_recovery / system_shutdown

注意 max_steps_exceededmax_steps_completed 是两个值:前者是硬超限,后者是 §4.2 的温柔收尾。默认大脑只会产出后者。

10.2 hooks:类型在内核,机制在服务端

packages/agent-runtime/src/types/hooks.ts 只放纯数据类型,文件头注释写得很直白:注册和派发机制(webhook 投递、序列化)住在服务端(hooks.ts:1-7)。

AgentHookType(:12-28)是 16 个生命周期挂载点:beforeStep / afterStepbeforeToolCall / afterToolCall / onToolCallErrorbeforeCompact / afterCompact / onCompactErrorbeforeHumanIntervention / afterHumanIntervention / onStopByHumanInterventionbeforeCallAgent / afterCallAgent / onCallAgentErroronCompleteonError

两个设计点:

  • beforeToolCall 能改写行为。 它的事件对象带一个 mock(result) 回调(hooks.ts:146-154),调了就跳过真实执行、直接返回假结果——测试和录制回放的基建。另有一个不带 mock 的孪生类型 BeforeToolCallObservationEvent(:160-168)用于生产 webhook 投递,从类型上就杜绝了"外部 webhook 篡改工具结果"
  • 错误带归属。 errorAttribution 字段把错误分成 user / provider / harness / system 四类"谁该修"(hooks.ts:64-70),让下游不用重新推导就能选对用户提示的措辞。配套的 errorType 是稳定错误码,注释明确要求消费者 switch 错误码而不是去正则匹配自由文本的 errorMessage(:76-82)。

11. 巧妙之处(可以直接借鉴的)

  1. 把"指令"定义成纯 JSON,是整个架构的支点。 一个约束换来四件事:跨执行面迁移、落库重放、无副作用单测大脑、mock 工具。代价是所有能力都要经由执行器注入(instruction.ts:392-413)。

  2. 超限降级而不是超限报错。 forceFinish 这一个布尔量串起了"剥工具 + 注总结提示 + 改压缩预算 + 换终止原因"四处行为,用户看到的是一段完整的收尾回答而不是一个红叉(runtime.ts:93-102)。

  3. [call_tools_batch, request_human_approve] 的混合下发。 安全的工具不用陪着危险的工具一起等人(GeneralChatAgent.ts:636-700),代价是引入了一条隐式的时序依赖(必须先执行后挂起)。

  4. 未决检查限定"当前轮"。 一个 parentId 比较就修掉了"陈旧 pending 消息永久卡死循环"的 bug,注释把现场完整记了下来(GeneralChatAgent.ts:361-447)。

  5. 安全黑名单两条路径都设卡。 全局审计器一条、InterventionChecker.shouldIntervene 一条,且都排在最前(InterventionChecker.ts:70-75)。刻意冗余,防的是"某条分支忘了检查"。

  6. 默认值一律取保守方向。 未知工具拦、无规则命中拦、压缩默认开。可以质疑它偶尔烦人,但方向明确。

  7. 把踩坑写进注释而非提交记录。 Gemini thoughtSignature 的误导性 400(state.ts:164-169)、Kimi K2 在任务结果后返回空内容所以要补一条虚拟 user 消息(GeneralChatAgent.ts:866)——这些经验直接长在代码旁边。


12. 边界与局限

诚实地说,这个包单独拿出来跑不了一个正经 agent:

  • 内置执行器只覆盖 7/14 种指令。 压缩、子 agent、中止收尾都必须注入,否则 step() 直接抛 No executor found(runtime.ts:183-186)。
  • 内置 call_tool 是 demo 级别的。 它从 agent.tools 这个 Record<string, fn> 里取函数(state.ts:174-177),没有超时、没有沙箱、没有权限、没有审批状态回写。真实执行见 第 4 章
  • messagesany[] 内核对消息形态零假设,好处是解耦,坏处是类型安全全靠上层自律。
  • 没有内建重试。 LLM 调用失败会走 createErrorResult(runtime.ts:942-958)把整个状态置为 error,重试策略留给执行面。
  • 驱动循环在包外。 step() 只走一步;死循环防护、取消检查、状态持久化的节奏,都由调用方负责。
  • interrupt() 没有真正记录断点。 interruptedInstruction 字段被显式写成 undefined,旁边留着注释 "Could be enhanced to store current instruction"(runtime.ts:270-274)。所以 resume() 恢复的是状态,不是执行位置。
  • 批量工具不限并发。 pMap 没传 concurrency(runtime.ts:732),一次返回 20 个工具调用就会同时发 20 个请求。

13. 代码地图(导航索引)

主题文件路径(相对克隆根)符号名
引擎主体packages/agent-runtime/src/core/runtime.tsAgentRuntime
单步循环packages/agent-runtime/src/core/runtime.tsAgentRuntime.step
执行器三级覆盖packages/agent-runtime/src/core/runtime.tsAgentRuntime 构造函数(this.executors = {...})
内置 LLM 执行器packages/agent-runtime/src/core/runtime.tscreateCallLLMExecutor
内置工具执行器packages/agent-runtime/src/core/runtime.tscreateCallToolExecutor
拦截工具的补消息packages/agent-runtime/src/core/runtime.tscreateResolveBlockedToolsExecutor
审批挂起packages/agent-runtime/src/core/runtime.tscreateHumanApproveExecutor
批量并发与合并packages/agent-runtime/src/core/runtime.tsexecuteToolsBatchmergeToolResults
成本超限处理packages/agent-runtime/src/core/runtime.tshandleCostLimitExceeded
状态工厂packages/agent-runtime/src/core/runtime.tsAgentRuntime.createInitialState
指令联合类型packages/agent-runtime/src/types/instruction.tsAgentInstruction
大脑接口packages/agent-runtime/src/types/instruction.tsAgentAgent.runner
相位定义packages/agent-runtime/src/types/instruction.tsAgentRuntimeContext.phase
可持久化状态packages/agent-runtime/src/types/state.tsAgentState
执行器签名 / 运行时配置packages/agent-runtime/src/types/runtime.tsInstructionExecutorRuntimeConfig
默认大脑packages/agent-runtime/src/agents/GeneralChatAgent.tsGeneralChatAgent.runner
审批分流packages/agent-runtime/src/agents/GeneralChatAgent.tscheckInterventionNeeded
API 级配置覆盖packages/agent-runtime/src/agents/GeneralChatAgent.tsgetToolInterventionConfig
参数匹配 / 动态审计packages/agent-runtime/src/agents/GeneralChatAgent.tsmatchesAlwaysPolicyresolveDynamicPolicy
当前轮未决工具packages/agent-runtime/src/agents/GeneralChatAgent.tsgetCurrentTurnPendingToolMessages
压缩前置判定packages/agent-runtime/src/agents/GeneralChatAgent.tstoLLMCallfindExistingSummary
图驱动大脑(装饰默认大脑)packages/agent-runtime/src/agents/GraphAgent.tsGraphAgent
审批判定器packages/agent-runtime/src/core/InterventionChecker.tsInterventionChecker.shouldIntervenematchesArgument
安全黑名单规则packages/agent-runtime/src/audit/defaultSecurityBlacklist.tsDEFAULT_SECURITY_BLACKLIST
黑名单审计器packages/agent-runtime/src/audit/createSecurityBlacklistAudit.tscreateSecurityBlacklistAudit
默认全局审计器packages/agent-runtime/src/audit/globalAudit.tscreateDefaultGlobalAudits
用量累加packages/agent-runtime/src/core/UsageCounter.tsUsageCounter.accumulateLLMaccumulateTool
用量与成本类型packages/agent-runtime/src/types/usage.tsUsageCostCostLimit
压缩阈值packages/agent-runtime/src/utils/tokenCounter.tsshouldCompressgetCompressionThreshold
步级上下文packages/agent-runtime/src/utils/stepContextComputer.tscomputeStepContext
阻塞态判定packages/agent-runtime/src/utils/status.tsisParkedStatusisBlockedStatus
事件与终止原因packages/agent-runtime/src/types/event.tsAgentEventFinishReason
生命周期钩子类型packages/agent-runtime/src/types/hooks.tsAgentHookTypeToolCallHookEvent
最小可跑示例packages/agent-runtime/examples/tools-calling.tsSimpleAgentmain
服务端注入执行器apps/server/src/services/agentRuntime/AgentRuntimeService.tscreateAgentRuntime
浏览器注入执行器src/store/chat/slices/agentRun/actions/transports/client/streamingExecutor.tscreateAgentExecutors 调用处
forceFinish 剥工具packages/context-engine/src/engine/tools/buildStepToolDelta.tsbuildStepToolDelta
forceFinish 注入总结提示packages/context-engine/src/providers/ForceFinishSummaryInjector.tsForceFinishSummaryInjector

14. 接着读哪一章

你想知道去哪章
call_llm 的 payload 里那堆消息和工具是怎么拼出来的02 · 上下文工程
agent.modelRuntime 背后怎么接住几十家供应商的流03 · 模型运行时
真实的 call_tool 执行器长什么样、一个 builtin tool 怎么落地04 · 工具体系
同一份 AgentState 怎么在浏览器/服务端/沙箱/CLI 之间迁移05 · 四个执行面
exec_sub_agent 系列指令、任务调度与对话树06 · 多 agent 与运营