一条消息的生命周期

从敲下回车到屏幕上出现回复——横穿 TUI、SDK、klient、Agent 级服务、human 状态机与工具层。

作者 David更新于 第 4 篇(共 33 篇)

这一篇把所有层串起来走一遍。细节留给后面各章,这里只建立全局地图,每一跳都给出 v0.43.0 里的落点。

全景

图表加载中…

逐层拆开

1. TUI 收输入

入口链写在 apps/kimi-code/AGENTS.md:src/main.ts → src/cli/commands.ts → src/cli/run-shell.ts → SDK KimiHarness → src/tui/kimi-tui.ts。run-shell.ts 用 createKimiHarness(harnessOptions) 建好 harness 交给 KimiTUI;/ 开头的 slash 命令在本地处理,普通输入由 sendMessageInternal 发出(kimi-tui.ts:1847):

this.staging.trackDispatch(stagingLease, session.prompt(sdkInput, { promptId: submissionId }), (error) => {
  this.failSessionRequest(`Failed to send: ${formatErrorMessage(error)}`);
});

goal 正在跑时改调 session.steer,把输入并进当前回合。kimi -p 不走这条线:cli/v2/run-v2-print.ts 自己 bootstrap() 引擎后直接 loop.submit。

2. SDK:进程内起引擎

createKimiHarness(packages/node-sdk/src/sdk-rpc-client-v2.ts:2728)先 new SDKRpcClientV2(options),再构造 KimiHarness。SDKRpcClientV2 的构造函数调引擎的 bootstrap()(app/bootstrap/bootstrap.ts:143),拿到 App scope 后包一层 klient:

this.app = app;
this.klient = createKlient({ scope: app });

Session.prompt(session.ts:138)校验后调 this.rpc.prompt(...);SDKRpcClientV2.prompt(第 1968 行)取会话的交互 agent——this.klient.session(sessionId).agent(this.interactiveAgentId)——调它的 prompt。SDK 注释逐处对照 v1:v1 在回合进行中会丢掉新 prompt,v2 按 FIFO 排队。

「RPC 是解耦缝,不必然是进程边界」在 v2 里换了形式:klient 内存传输从不出进程,但每个值都做一次 JSON.parse(JSON.stringify(...)) 往返(klient/src/transports/memory/dispatcher.ts:67),与 ipc 传输返回字节一致的数据。换个传输就能跨进程,行为不变。

3. klient:契约与路由

门面把 prompt 映射成一次「服务名 + 方法」调用(klient/src/core/facade/agent.ts:108):

prompt: (input) =>
  call(scope, 'agentPromptService', 'submit', [input]) as Promise<PromptLaunchResult>,

契约 agentPromptContract(contract/agent/services.ts:31)给出入参与出参的 zod schema,两头都校验。内存分发器的 resolveScope(dispatcher.ts:290)把 ScopeRef 解析到具体的 Session / Agent scope,与 kap-server 的同名函数同构,必要时用 ensureMainAgent 补建主 agent。

4. 引擎入口:Agent 级的 prompt 通道

'agentPromptService' 是 IAgentPromptChannel 的 DI 标识(agent-core-v2/src/agent/loop/promptChannel.ts),以 LifecycleScope.Agent、ScopeActivation.OnDemand 注册。submit(第 40 行)先为主 agent 更新会话标题与最后一条 prompt,再把输入交给 loop:

const { id } = this.loop.submit({
  message: { role: 'user', content: [...payload.input] },
  meta: { promptId: payload.promptId, origin: { kind: 'user' }, tracked: true },
});
if (status.state === 'running' || status.paused || status.queue.length > 0) return undefined;

loop 忙时 prompt 进队列、返回 undefined;空闲时等回合真正启动,返回 turn_id。

5. AgentLoopService 与 human 状态机

IAgentLoopService(agent/loop/loop.ts:204)是 Agent 级服务,管 prompt 队列、取消、快照与 step 钩子。循环本身不在它身上,而在 src/human/ 的 agent / turn / tool 三台纯状态机里;agent/loop/machine/engine.ts:329 把后两台接到引擎服务上:

turnLogic: createTurnMachine(requester.requester, {
  retry: { maxAttemptsPerStep: options.maxAttemptsPerStep },
  recovery: {
    propose: (ctx) => credentialsRecovery.propose(ctx) ?? options.recovery?.propose(ctx),
  },
}),
toolLogic: createToolMachine(tools.executor),

createMachineRequester 把 generate 接到 IAgentLLMRequesterService,createMachineTools 接到 IAgentToolExecutorService;状态机基于 xstate。turn 状态机在 thinking 收到 llm.done 时决定下一步(human/agent/turn.ts:572,有删减):

'llm.done': [
  {
    guard: ({ context }) =>
      context.accumulator.finish().message.toolCalls.length > 0,
    target: 'acting',
  },
  { target: 'done' },
],

有工具调用就进 acting,否则回合结束;空回复转成 llm.failed.remote,重试走 retrying,中止走 aborted。回合结果 LoopRunResult(loop.ts:101)是 completed、failed(带 error)、cancelled(带 reason)三选一。

6. 调用模型

createMachineRequester(machine/requester.ts:49)调 IAgentLLMRequesterService.start,把流式 part 转成事件;请求一结束先发用量(第 93 行起):

const finish = await task.result;
lastFinish = finish;
control.onEvent?.({ type: 'llm.streaming.usage', usage: finish.usage });

engine.ts 在 llm.done 时就带着 usage 发出 stepCompleted,工具要等进入 acting 才执行。请求层在 src/human/llm/,llm-adapter/protocol/protocolAdapterRegistry.ts 按模型的 protocol 路由到 openai、openai_responses、anthropic、google-genai 四种协议基座。

7. 工具执行

acting 把工具调用交给 tool 状态机,经 createMachineTools 进入 IAgentToolExecutorService,由 ToolScheduler 排队:与正在跑或排在前面的任务有资源冲突就等,否则并行。冲突规则在 tool/toolContract.ts:191:

function resourceAccessesConflict(left: ToolResourceAccess, right: ToolResourceAccess): boolean {
  if (left.kind === 'all' || right.kind === 'all') return true;
  if (!fileOperationsConflict(left.operation, right.operation)) return false;
  return fileAccessesOverlap(left, right);
}

all 与一切冲突;两边都不写就不冲突;否则看路径是否重叠——比较前统一斜杠并小写折叠。

8. 事件回流与落盘

loop 经 IEventDispatcher 发出 turn.started、turn.step.*、assistant.delta 等领域事件(agent/loop/turnEvents.ts),分发器再 publish 到 IEventBus(state/eventDispatcherService.ts:623)。SDK 的 SessionEventWiring 从每个活着的 agent 取 IEventBus 订阅(node-sdk/src/v2/session-wiring.ts:162,中途新建的子代理也算),经 translateDomainEvent 译成 SDK 事件交给 receiveEvent;TUI 挂在 session.onEvent 上的 SessionEventHandler.handleEvent(controllers/session-event-handler.ts:269)按 type 分派,最后由 pi-tui 差分重绘。可见调用经 klient,事件却直接挂在 SDK 自己 bootstrap 出的 scope 上。

审批与提问是反方向:v2 把待办交互停在进程级的交互内核里,SessionEventWiring 监听 onDidChangePending,交给 setApprovalHandler 等注册的处理器,TUI 的 reverse-rpc/ 把它们变成面板,用户的选择经内核的 respond 写回。

落盘这一侧,loop 用 wireStoreJournal(loopService.ts:203)把状态机的事件存储接到 wire 服务,记录键是 wire.jsonl;wire 协议当前为 1.5,旧记录经 wire/migration/ 逐级迁移。

两条值得记住的约定

  1. prompt 不丢:loop 忙时新 prompt 按 FIFO 排队;想插进当前回合要显式 steer。
  2. 用量先于工具记账:模型请求一结束,usage 就随 stepCompleted 发出,工具被中止也不影响。

接下来

第 1 部分从Agent 循环:三台状态机开始,把这里一笔带过的 loop 与状态机拆开细看。

本页目录