cumoraAI
§ · Nested detail page

Turn Stream and Tools 深度解析

这一页跟踪一次 agent turn 如何消费 inbox、处理 Responses stream、执行工具并留下审计记录。

§ 00 · IMPORT CHAIN

导入链与契约

server/src/agents/turn.ts:20 到 server/src/agents/turn.ts:66 显示 runAgentTurn 的依赖非常宽:OpenAI Responses、triage、pod tools、FS namespace、runtime client、status、model policy、stream reducer、cost ledger 和 steer。它是系统的业务核心,所以源码把多个易测子模块拆出来。

server/src/agents/turn.ts · L20-66TypeScript
20import type { ResponseInputItem, ResponseStreamEvent } from 'openai/resources/responses/responses'\n21import { env } from '../env.js'\n22import { redis } from '../redis.js'\n23import { classifyInboxTriage, gateSyntheticWake } from './inbox-triage.js'\n24import { GLANCE_YIELD_RULES } from './glance-protocol.js'\n25import { TOOL_DEFS_RESPONSES, executePodTool } from './runtime/pod-tools.js'\n26import {\n27  TURN_STATUS_VALUES,\n28  bashOutputHasReplySideEffect,\n29  bashOutputSideEffects,\n30  bashOutputSideEffectChannelUnreliable,\n31  type CliSideEffect,\n32  type ToolResult,\n33  type TurnStatusOutput,\n34  type TurnStatusValue,\n35} from './tools-shared.js'\n36import { hydrate as hydrateFs, commit as commitFs, teardown as teardownFs, type FsNamespace } from './runtime/fs-namespace.js'\n37import type { PollWakeBrief } from './runtime/wake-bus.js'\n38import { runtime } from './runtime/select.js'\n39import type { AgentRuntimeClient } from './runtime/client.js'\n40import { BUSY_STATUS_HEARTBEAT_MS } from '../status.js'\n41import { errorText, type AgentRunStatus } from './observability.js'\n42import { getLlmClient } from '../llm.js'\n43import { enforceModelPolicy, realTaskModel, supportModel } from './model-policy.js'\n44import { materializeImage } from './image-fetcher.js'\n45import {\n46  applyResponseStreamEvent,\n47  consumeResponseStream,\n48  newResponseStreamState,\n49  type ResponseStreamState,\n50} from './turn-stream.js'\n51import { compactHistoryWithSummary, estimateHistoryTokens, estimateTokens } from './turn-compaction.js'\n52import { usageFromOpenAI, addUsage, EMPTY_USAGE, type TokenUsage } from './cost.js'\n53import { recordLlmCall, classifyLlmCallError, readStreamUsage, readStreamReasoningTokens } from './llm-ledger.js'\n54import { resolveDeclaredAutoRelayTarget } from './auto-relay.js'\n55import {\n56  canDrainSteer,\n57  drainSteer,\n58  resetSteerForAgent,\n59  isSteerSaturated,\n60  isSteerByteBudgetExhausted,\n61  recordSteerBytes,\n62  registerActiveToolBatch,\n63  clearActiveToolBatch,\n64  SUMMARIZE_THRESHOLD as STEER_SUMMARIZE_THRESHOLD,\n65  type SteerItem,\n66} from './steer.js'
§ 01 · CORE IMPL

核心实现,逐段拆

▸Response stream reducer 是单一真相

server/src/agents/turn-stream.ts:29 到 server/src/agents/turn-stream.ts:60 定义 pendingTools、responseTextByPart、usage 等状态;server/src/agents/turn-stream.ts:140 到 server/src/agents/turn-stream.ts:217 再把不同事件归约进去。这样工具调用 arguments 丢 delta 或 completed output 为空,都不会让 turn replay 分裂。

server/src/agents/turn-stream.ts · L29-90TypeScript
29export interface PendingToolCall {\n30  call_id: string\n31  name: string\n32  arguments: string\n33}\n34\n35export interface ResponseStreamState {\n36  pendingTools: Record<string, PendingToolCall>\n37  responseTextByPart: Map<string, string>\n38  responseId?: string\n39  responseStatus?: string\n40  /** Traced (truncation-safe) representation of `response.completed.output`,\n41   *  rendered eagerly via the optional `traceResponseOutputItem` callback\n42   *  so the observability event can stay small even when the underlying\n43   *  response carries giant tool outputs. */\n44  responseOutput: unknown[]\n45  responseUsage: unknown\n46  /** Sum of `usage.input_tokens + usage.output_tokens` from the\n47   *  `response.completed` event. The caller surfaces this to the per-turn\n48   *  75%-budget guard; reset every hop. */\n49  totalTokens: number\n50}\n51\n52export function newResponseStreamState(): ResponseStreamState {\n53  return {\n54    pendingTools: {},\n55    responseTextByPart: new Map<string, string>(),\n56    responseOutput: [],\n57    responseUsage: null,\n58    totalTokens: 0,\n59  }\n60}\n61\n62export interface ApplyStreamEventOptions {\n63  /** Optional mapper for `response.completed.output` items. The production\n64   *  caller passes `traceResponseOutputItem` to render each item into a\n65   *  truncation-safe shape for observability events. Tests can omit this\n66   *  to keep raw items, which makes assertions easier. */\n67  traceItem?: (item: unknown) => unknown\n68}\n69\n70export interface ConsumeStreamOptions {\n71  /** Maximum silence between stream events. Once the HTTP stream has been\n72   *  established, the OpenAI SDK request timeout no longer protects every\n73   *  `for await` read; this bounds the no-progress case explicitly. */\n74  idleTimeoutMs?: number\n75  /** Maximum wall-clock time for one model hop stream, even if it dribbles\n76   *  occasional events. Agent tasks should continue in another hop, not pin\n77   *  a worker forever inside one response body. */\n78  wallTimeoutMs?: number\n79}\n80\n81function abortStream(stream: AsyncIterable<ResponseStreamEvent>): void {\n82  const maybeAbort = (stream as unknown as { controller?: { abort?: () => void } }).controller?.abort\n83  if (typeof maybeAbort === 'function') {\n84    try {\n85      maybeAbort.call((stream as unknown as { controller?: unknown }).controller)\n86    } catch {\n87      // Best effort only. The caller is already failing the turn.\n88    }\n89  }\n90}

▸流式读取有 idle 与 wall 双 timeout

server/src/agents/turn-stream.ts:110 到 server/src/agents/turn-stream.ts:138 通过 nextWithTimeout 控制每次 iterator.next,既防止长时间没有 event,也防止流一直滴答但永远不结束。这对 agent turn 很关键,因为每个 turn 都占用运行时并可能阻塞后续 wake。

server/src/agents/turn-stream.ts · L110-138TypeScript
110export async function consumeResponseStream(\n111  stream: AsyncIterable<ResponseStreamEvent>,\n112  onEvent: (event: ResponseStreamEvent) => void,\n113  opts: ConsumeStreamOptions = {},\n114): Promise<void> {\n115  const idleTimeoutMs = opts.idleTimeoutMs ?? RESPONSE_STREAM_IDLE_TIMEOUT_MS\n116  const wallTimeoutMs = opts.wallTimeoutMs ?? RESPONSE_STREAM_WALL_TIMEOUT_MS\n117  const deadline = Date.now() + wallTimeoutMs\n118  const iterator = stream[Symbol.asyncIterator]()\n119\n120  while (true) {\n121    const remainingWallMs = deadline - Date.now()\n122    if (remainingWallMs <= 0) {\n123      abortStream(stream)\n124      throw new ResponseStreamTimeoutError('wall', wallTimeoutMs)\n125    }\n126    const waitMs = Math.min(idleTimeoutMs, remainingWallMs)\n127    const timeoutKind = waitMs === remainingWallMs ? 'wall' : 'idle'\n128    let result: IteratorResult<ResponseStreamEvent>\n129    try {\n130      result = await nextWithTimeout(iterator.next(), waitMs, timeoutKind)\n131    } catch (err) {\n132      if (err instanceof ResponseStreamTimeoutError) abortStream(stream)\n133      throw err\n134    }\n135    if (result.done) return\n136    onEvent(result.value)\n137  }\n138}

▸工具执行先落 pending,再写 result

server/src/agents/tools.ts:43 到 server/src/agents/tools.ts:116 展示 server-side tool executor 的审计形态:先插入 tool_calls pending,再 dispatch,最后 update result/status/error/duration。即使工具失败,server/src/agents/tools.ts:101 的 catch 也会形成结构化错误。

server/src/agents/tools.ts · L43-116TypeScript
43/** Dispatch a single tool call. Logs to tool_calls table. */\n44export async function executeTool(args: {\n45  agentId: string\n46  name: string\n47  argsJson: string\n48  messageId?: string | null\n49  runId?: string | null\n50  companyId?: string | null\n51  /** Per-turn FS namespace. When provided, bash runs with cwd\n52   *  = ns.rootDir and the three native FS tools operate on it.\n53   *  Optional so other call sites (rehire, manual replay) can keep\n54   *  running without a namespace. */\n55  ns?: FsNamespace | null\n56}): Promise<ToolResult> {\n57  const t0 = Date.now()\n58  const id = `t-${randomUUID()}`\n59  let parsed: Record<string, unknown> = {}\n60  try {\n61    parsed = JSON.parse(args.argsJson || '{}')\n62  } catch {\n63    parsed = {}\n64  }\n65\n66  await pool.query(\n67    `INSERT INTO tool_calls (id, message_id, agent_id, name, args, status, run_id, company_id)\n68     VALUES ($1,$2,$3,$4,$5::jsonb,'pending',$6,$7)`,\n69    [id, args.messageId ?? null, args.agentId, args.name, JSON.stringify(parsed), args.runId ?? null, args.companyId ?? null],\n70  )\n71\n72  let result: ToolResult\n73  try {\n74    switch (args.name) {\n75      case 'palette':            result = await tPalette(parsed, args.companyId ?? null, args.agentId); break\n76      case 'dm_with':            result = await tDmWith(parsed, args.agentId); break\n77      case 'pull_group':         result = await tPullGroup(parsed, args.agentId); break\n78      case 'set_turn_status':    result = tSetTurnStatus(parsed); break\n79      case 'shell':\n80      case 'bash':               result = await tBash(parsed, args.agentId, args.ns ?? null); break\n81      case 'react':              result = await tReact(parsed, args.agentId); break\n82      // Native FS tools — require an active per-turn namespace. Reject\n83      // gracefully if called outside a turn (shouldn't happen via the\n84      // normal LLM path; defensive).\n85      case 'read_file':\n86        result = args.ns ? await tReadFile(parsed, args.ns) : noNamespace('read_file', args.name)\n87        break\n88      case 'write_file':\n89        result = args.ns ? await tWriteFile(parsed, args.ns) : noNamespace('write_file', args.name)\n90        break\n91      case 'edit_file':\n92        result = args.ns ? await tEditFile(parsed, args.ns) : noNamespace('edit_file', args.name)\n93        break\n94      default:\n95        result = {\n96          ok: false, output: null, error: 'unknown tool',\n97          durationMs: Date.now() - t0,\n98          display: { name: args.name, arg: '', status: 'unknown tool', detail: `tool not implemented: ${args.name}` },\n99        }\n100    }\n101  } catch (err) {\n102    result = {\n103      ok: false, output: null, error: String(err), durationMs: Date.now() - t0,\n104      display: { name: args.name, arg: JSON.stringify(parsed).slice(0, 80), status: 'error', detail: String(err) },\n105    }\n106  }\n107\n108  await pool.query(\n109    `UPDATE tool_calls\n110        SET result = $2::jsonb, status = $3, error = $4, duration_ms = $5\n111      WHERE id = $1`,\n112    [id, JSON.stringify(result.output ?? null), result.ok ? 'ok' : 'error', result.error ?? null, result.durationMs],\n113  )\n114\n115  return result\n116}
§ 02 · KEY FLOW

关键流程

runAgentTurn → loadInbox / loadMemory / loadContext → Responses stream → pendingTools → executePodTool or cumora CLI → side effect + status + cost ledger

这一链路的关键边界分别在 server/src/agents/turn.ts:141 的 loadInbox wrapper、server/src/agents/runtime/inproc-client.ts:105 的 unread tail SQL、server/src/agents/tools.ts:147 的 pull_group 动作和 server/src/agents/tools.ts:180 的 react 动作。

§ 03 · EDGE CASES

边界 / 失败模式

  • server/src/agents/turn-stream.ts:146 说明 completed 事件会 back-fill pendingTools,修复上游 dropped output_item 的情况。
  • server/src/agents/tools.ts:172 的 noNamespace 防止文件工具在没有 per-turn FS namespace 时静默写错地方。
  • server/src/agents/tools.ts:231 明确 react 不推进 read cursor,避免长任务 ack 把未完成任务从 inbox 擦掉。
§ 04 · RELATED

关联模块

steer
server/src/agents/steer.ts:1 承接 mid-turn 注入,server/src/agents/steer.ts:43 负责把 steer item 渲染回模型上下文。
cost ledger
server/src/agents/cost.ts:1 记录 token usage。
Sub-page · 2026-08-18↑ cumora 主页面