// The IM main loop. This is the orchestrator. The shell is a "valve" + // guard-runner, the protocol is the "delivery mechanism", the registry is // the "tool phonebook", the databus is the "conversation log". IM is the // agent's brain. // // v0.17: extracted from a 633-line monolith into a thin driver + // 5 hook call points + 4 pure helper functions. Behavior is 100% // equivalent — 1211 tests pre-refactor still pass post-refactor. The // extraction exists so the hook call points are obvious to read and so // each phase (compose / shellCall / guards / toolExecution / toolResult) // can be reasoned about and tested as a unit. // // ── Hook philosophy (user decision 2026-09-05) ───────────────────────────── // Hooks are NOT a plugin API. This harness ships no third-party extensions // and is not built for any: the only consumer of hooks is the harness // itself (SignalBus wiring, rendering, error recovery). Their purpose is // INFORMATION-FLOW management, not feature or code management — each hook // point is a branching seam where the state machine emits part of its // information stream to a side channel (e.g. afterToolExecution → // RenderingSignalBus), while the main loop's context engineering stays // intact. Therefore: helpers stay INSIDE this file (the state machine // serves IM; AtomCode agent.rs takes the same stance — a 5.5k-line state // machine whose hooks are internal seams only). Do NOT move loop logic // into hooks, and do NOT add hooks to serve external users. import type { ToolRegistry } from '../shell/registry.js' import type { ShellConfig } from '../shell/config.js' import type { Mailbox } from './mailbox/index.js' import type { SystemAgent } from './system-agent.js' import type { StateLine } from './state-line/types.js' import type { ToolContext } from '../shared/tool-context.js' import { shellCall, type ShellDeps, type ShellCallResult } from '../shell/call.js' import { compose, type FinalPrompt, type PromptPart } from '../shell/compose.js' import type { State } from '../shell/state.js' import { runGuards, type GuardHit } from '../shell/guards.js' import { ShellTerminatedError } from '../shell/gate.js' import { createMetrics, addToolError, resetToolErrors, type Metrics, } from '../shell/metrics.js' import type { StreamChunk, ToolCall } from '../protocol/types.js' import { ProtocolError } from '../protocol/types.js' import { parseToolCallArguments } from '../protocol/tool-calls.js' import type { ConversationTurn, ConversationMemory } from './conversation-memory.js' import { turnToMessage, turnToMessageWithCounter, appendCanonicalTurn, appendToolResultWithProjection, mintTurnId } from './turn.js' import type { ToolTurn, AgentId } from './databus.js' import type { Databus } from './databus.js' import { MultiDatabus } from './multi-databus.js' import type { ToolCategory } from '../shell/registry.js' import { CONCURRENCY_LIMITS } from '../shell/registry.js' import { buildContextProjection } from './context-projection.js' import { DEEPSEEK_TOKEN_COUNTER, type TokenCounter } from '../shared/token-counter.js' import { createDirectRecallLedger, type DirectRecallLedger } from './tools/databus-recall.js' import { buildServerSummary, buildDynamicToolSchemaMessage, type DynamicToolSource } from './dynamic-tool-context.js' import type { OpenAITool } from '../protocol/types.js' import type { DriveCoordinator } from './system-agents/drive-coordinator.js' import type { Logger } from '../shared/logger.js' import { defaultLogger } from '../shared/logger.js' import type { MemoryConfig } from '../shell/memory-config.js' import { DEFAULT_MEMORY_CONFIG } from '../shell/memory-config.js' import { callHook, type LoopHooks, type AfterShellCallContext, type AfterShellCallResult, } from './loop-hooks.js' /** Max retries for ProtocolError (transient network/SSE failures). */ const MAX_PROTOCOL_ERROR_RETRIES = 3 /** Base delay (ms) between ProtocolError retries. Doubles each retry. */ const PROTOCOL_ERROR_RETRY_BASE_MS = 1000 // v0.10.1c: The three system agents available to the working agent's tools. // The loop itself does not call these agents — they are used by the 7 new // system-agent-backed tools via closures. Including the type here ensures // the wiring is explicit at the IMLoopOptions level. export type SystemAgents = { warehouse: SystemAgent compressor: SystemAgent recall: SystemAgent } // advanceElapsed: a one-liner that updates metrics.elapsedMs from a wall-clock // anchor. It exists so the time guard's `metrics.elapsedMs > maxElapsedMs` // check actually has a producer; without it the guard's threshold is // unreachable (ADR-009: every guard must be reachable on the happy path). export const advanceElapsed = (m: Metrics, loopStart: number, now: number): Metrics => ({ ...m, elapsedMs: now - loopStart, }) export type IMLoopOptions = { config: ShellConfig registry: ToolRegistry databus: Databus conversationMemory: ConversationMemory workingAgentId: AgentId mailbox: Mailbox systemAgents: SystemAgents driveCoordinator?: DriveCoordinator initialMetrics?: Metrics streamChat: ( url: string, request: { model: string; messages: FinalPrompt['messages']; tools?: FinalPrompt['tools']; [k: string]: unknown }, ) => AsyncIterable url: string model: string systemPrompt: string userTemplate: string systemToolRefs: string[] mcpRefs: { server: string; refs: string[] }[] skillRefs: string[] stateLine: StateLine // v0.11: optional contextual databus for tools. When set, ctx.databus // points to this bus (or a merged view of multiple buses) instead of the // loop's private databus. The private databus still receives this loop's // own tool-turn projections; tools read the contextual bus (e.g. sub-agent // events, working agent history). // An array of buses is wrapped in a MultiDatabus read-only merged view. ctxDatabus?: Databus | readonly Databus[] // v0.11.1 P2.3: recursion depth for nested run_subagent calls. // 0 = working agent (top level). Each run_subagent increments by 1 // before launching the sub-agent's loop. subAgentDepth?: number // v0.12.3 stage 2b: compression zone for system agents. Set by system-agent.ts // from input.metadata.zone. Propagated to ToolContext.compressZone so // record_curated_block can fall back to the correct layer when the LLM // doesn't explicitly pass one. compressZone?: 'M1' | 'M2' injectTextSkills?: boolean archiveSourceStamps?: string[] archiveRawArchiveIds?: string[] isWikiAgent?: boolean sessionId?: string requestHandler?: (kind: string, payload: unknown) => Promise logger?: Logger | undefined memoryConfig?: MemoryConfig // v0.17.x: optional abort signal. When triggered, the loop throws // `AbortError` between hook calls (after each await boundary) so callers // can cancel a long-running turn. Matches KimiCode's RunTurnInput.signal // pattern — optional here so existing callers that never opt in are not // affected; when absent, the loop synthesizes an AbortController that is // never aborted so the signal checks are no-ops in the steady state. signal?: AbortSignal | undefined persistTurn?: (turn: ConversationTurn, databusTurn?: ToolTurn) => Promise // 思维链落盘门控(2026-09-12 用户拍板:赋值面 = 落盘面)。仅当本 loop 的 // 会话确有持久化归宿时才把聚合 reasoning 赋进 assistant 回合:工作代理 // (persistTurn → conversation.jsonl)与 warehouse 持久会话(persistPath → // warehouse-session.jsonl)置 true;子代理 / compressor / recall 无落盘 // 归宿,缺省 false 不赋值——思维链只活在实时 thinking.delta 信号里。 persistReasoning?: boolean // v0.17: per-loop control-flow hooks. Omit entirely for current behavior. hooks?: LoopHooks // v0.18: progressive tool disclosure — accumulated dynamic tool schemas. // Starts empty; each round, after tool execution, load_tools may attach // schemas to ctx._loadedDynamicTools. The loop collects them here so the // next round's compose includes them as dynamicSchema parts. dynamicSchemas?: Array<{ source: DynamicToolSource; tools: OpenAITool[] }> // v0.18: sub-agent tool policy, passed to ToolContext for load_tools filtering. toolPolicy?: import('../im/sub-agent/policy.js').SubAgentToolPolicy // Effective runtime boundary for system agents and sub-agents. allowedToolRefs?: readonly string[] directRecallLedger?: DirectRecallLedger directRecallLimitTokens?: number largeRecall?: ToolContext['largeRecall'] // v0.20: 用户工作文件夹(前端/宿主创建会话时传入)。 // 上下文注入源(MEMORY.md / ARCHITECTURE.md)据此根目录读取。 // 不是 process.cwd()——进程 cwd 可能是宿主目录,不是用户工作文件夹。 workDir?: string // v0.19: context injection hook — replaces hardcoded dynamic content injection. // When set, loop calls contextInjector.inject() for dynamic messages each round. // When absent, falls back to existing hardcoded injection logic (backward compat). contextInjector?: import('./hooks/context-injection.js').ContextInjector // v0.19: layered prompt configuration. Currently not wired into composePrompt; reserved. // promptLayers?: import('./prompt/types.js').PromptLayer[] // promptMode?: import('./prompt/types.js').PromptMode // v0.19: hook system — general-purpose event hooks for tool execution lifecycle. hookSystem?: import('./hooks/hook-system.js').HookSystem // v0.21: 流式增量旁路(可选观察者)。每轮 shellCall 消费 streamChat chunk // 时,loop 以当前轮的 turnId(mintTurnId('turn'),全局唯一)为上下文转发给 // 这里;信号关的 delta-bridge 经此接入(src/signals/wiring/delta-bridge.ts)。 // 纯观察,不改变聚合语义;默认 undefined 时零行为变化。 onStreamChunk?: ((turnId: string, chunk: import('../protocol/types.js').StreamChunk) => void) | undefined /** * v0.41 D19:provider 是否要求严格角色交替。true 时 shellCall 出站前合并 * 相邻的 role:'user' 消息(src/protocol/messages.ts)。事实源是 * providers.json 的 capabilities.strictAlternation,装配层逐轮热解析后透传 * (与 /provider use 热切换一致)。缺省 undefined = 不转写 = wire 形状零变化。 */ strictAlternation?: boolean | undefined /** Host-selected provider/model counter; omitted callers keep legacy DeepSeek behavior. */ tokenCounter?: TokenCounter | undefined } export type IMLoopResult = { terminated: boolean reason: 'completed' | 'guard-tripped' | 'protocol-error' | 'shell-terminated' finalState: State hits: GuardHit[] turns: number metrics: Metrics } // ---------------------------------------------------------------------------- // Pure helpers (extracted from the monolith; identical behavior, no hooks). // ---------------------------------------------------------------------------- // v0.20 (ADR-025 Part 2): RoundState 封装循环状态变量。 // 四散突变的 state/metrics/turns/lastMetrics/protocolErrorRetries 集中管理, // 通过 hook ctx 传递,所有 hook 通过 ctx 读写状态。 interface RoundState { state: 'Running' | 'Tripped' metrics: Metrics turns: number lastMetrics: Metrics | null loopStart: number protocolErrorRetries: number } // v0.20 (ADR-025 Part 2): composePrompt 抽出为纯函数。 // 职责:组装 prompt parts(上下文注入 + 历史 + 用户消息 + 工具描述)。 // 不改变上下文组装顺序:system → afterSystem injections → history → userTemplate → afterUser injections → stateLine → tools → summaries → dynamicSchemas → skillText。 async function composePrompt(opts: IMLoopOptions, state: RoundState, estimatedTokens: number): Promise { const projection = buildContextProjection({ stateLine: opts.stateLine, mailbox: opts.mailbox, workingAgentId: opts.workingAgentId, estimatedTokens, ...(opts.memoryConfig ? { memoryConfig: opts.memoryConfig } : {}), availableToolRefs: opts.systemToolRefs, }) const serverSummary = buildServerSummary(opts.registry) const conversationTurns = opts.conversationMemory.turns() let afterSystemInjections: import('./hooks/context-injection.js').ContextInjectionResult[] = [] let afterUserInjections: import('./hooks/context-injection.js').ContextInjectionResult[] = [] if (opts.contextInjector) { const injectionResults = await opts.contextInjector.inject({ conversationHistory: conversationTurns as unknown as readonly unknown[], registry: opts.registry, sessionId: opts.sessionId, agentId: opts.workingAgentId, round: state.metrics.stepCount, // v0.20: 用户工作文件夹——注入源据此读 MEMORY.md/ARCHITECTURE.md workDir: opts.workDir, }) afterSystemInjections = injectionResults.filter(r => r.position === 'afterSystem') afterUserInjections = injectionResults.filter(r => r.position === 'afterUser') } const parts: PromptPart[] = [ { type: 'system', content: opts.systemPrompt + projection.systemSuffix }, // v0.19: afterSystem injections (runtime, temporal) — after system prompt. ...afterSystemInjections.map(r => ({ type: 'system' as const, content: r.content })), // v0.19: conversation history — stable prefix right after system prompt. ...conversationTurns.map(turn => ({ type: 'turn' as const, message: turnToMessageWithCounter(turn, opts.tokenCounter) })), // Current round user message. v0.27 去重 → v0.41 后续补丁 (b):入口已无条件 // 把 userTemplate 落成 canonical user 回合(loop.ts:693),history part 已含 // 该文本,跳过 userTemplate part——用户文本每轮恰好一次。判据用构造性事实 // (userTemplate 非空 = 已落 canonical),不再内容扫描:u1 被压缩逐出后不复活 // (见 omitUserTemplatePart 注释)。 ...(omitUserTemplatePart(opts.userTemplate) ? [] : [userTemplatePart(opts.userTemplate)]), // v0.19: afterUser injections (MEMORY.md) — after user message. ...afterUserInjections.map(r => ({ type: 'system' as const, content: r.content })), // Other parts (stateLine, tools, summaries). ...(projection.stateLinePart ? [projection.stateLinePart] : []), ...opts.systemToolRefs.map(ref => ({ type: 'systemTool' as const, ref })), ...(serverSummary ? [{ type: 'mcpServerSummary' as const, content: serverSummary }] : []), ...(opts.dynamicSchemas ?? []).map(ds => ({ type: 'dynamicSchema' as const, tools: ds.tools, sourceLabel: ds.source.kind === 'mcp' ? `MCP server "${ds.source.server}"` : `skill "${ds.source.name}"`, })), ] if (opts.injectTextSkills !== false) { for (const ts of opts.registry.getTextSkills()) { const content = ts.whenToUse === undefined ? ts.body : `\n${ts.body}` parts.push({ type: 'skillText', content, skillName: ts.name }) } } return parts } /** * v0.27 → v0.41 后续补丁 (b):本轮用户文本是否已进 canonical。 * * 入口 append 是**无条件的**(loop.ts:693:userTemplate 非空必落 canonical user * 回合),所以"本轮用户文本是否已在 canonical" ≡ "userTemplate 是否非空"——这是 * 构造性事实,不需要每轮运行时重推导。 * * v0.27 原判据是内容扫描 `turns.some(t => t.content === userTemplate)`,它是对 * 上述构造性事实的**运行时重推导**。G1/常规压缩把 u1 逐出 canonical(原位换成 * mem- 信封)后,扫描不再命中 → userTemplate part 在每轮请求尾部被重新注入: * 一份**不在 canonical 里的幻影**(wire ≠ 落盘 ≠ 模型视野),既抵消压缩、又是 * live-only(恢复/resume 从 canonical 重建,幻影消失,前后不一致)。 * * 改用构造性事实根治:u1 逐出后**不复活**。模型视野 = canonical = 信封的 * evidence_fragments 头锚(inputKeepTokens,默认 1K);u1 全文在 raw-archive, * 经 state_query({stamps}) 按戳召回。空 userTemplate 仍返回 false(保持既有 * 空 part 行为,系统智能体等调用方不受影响)。 */ function omitUserTemplatePart(userTemplate: string): boolean { return userTemplate !== '' } /** composePrompt 的当前轮 user 消息 part(类型字面量需要具名常量保型)。 */ const userTemplatePart = (content: string): PromptPart => ({ type: 'userTemplate', content }) // v0.20 (ADR-025 Part 2): finalizeRound 抽出为纯函数。 // 职责:一轮结束后的持久化(appendAssistantTurn + fireDriveCoordinator)。 // 位置:HOOK 3 之后、HOOK 4 之前。不可干预的固定收尾。 async function finalizeRound( result: ShellCallResult, opts: IMLoopOptions, state: RoundState, estimatedTokens: number, log: Logger, ): Promise { // Record the assistant turn in the databus. const assistantMsg = result.response.choices[0]?.message const now = Date.now() const assistantTurn: ConversationTurn = { id: mintTurnId(`assistant-${state.turns}`), role: 'assistant', content: assistantMsg?.content ?? null, at: now, } if (result.toolCalls.length > 0) { assistantTurn.toolCalls = result.toolCalls } // 思维链落盘(2026-09-12 用户拍板,赋值面 = 落盘面):聚合器给出的 // reasoning 原样入 canonical 回合——仅 persistReasoning 开启的 loop // (工作代理 / warehouse 持久会话)。恢复链路对 ConversationTurn 直存直读 // 无需额外接线;turnToMessage 不投影该字段,后续请求不受影响。 if (result.reasoning !== undefined && opts.persistReasoning === true) { assistantTurn.reasoning = result.reasoning } appendCanonicalTurn(opts.conversationMemory, opts.databus, assistantTurn) if (opts.persistTurn) await opts.persistTurn(assistantTurn) } // Run every tool call in the batch, returning one `role: 'tool'` Turn per call. // Pure: no global state, no databus side effects. // // v0.20 hook repair: 按 category 分桶并发,废除硬编码 MAX_CONCURRENT_TOOL_CALLS = 2。 // - 先按 getToolCategory 分组(保留原始下标) // - 每组按 CONCURRENCY_LIMITS[category] 切批并行(read=5 / write=3 / command=3) // - 最终结果按原始下标排序,保证与输入 toolCalls 顺序一致(LLM 配对依赖此顺序) // // v0.20 (ADR-025 T8): parallelSafe 逐参数判定(AtomCode `parallel_safe(&self, args)`)。 // category 桶是默认/保守值;工具声明的 parallelSafe(args) === true 是精确覆盖—— // 该调用进入单独的并行安全桶,全部同时执行(不占用 category 桶的并发槽)。 // 判定失败(工具未声明 parallelSafe / 参数解析失败 / 谓词抛出)一律按 category 默认值。 const isParallelSafeCall = (tc: ToolCall, registry: ToolRegistry): boolean => { const sysTool = registry.getSystemTool(tc.function.name) if (!sysTool?.parallelSafe) return false const parsed = parseToolCallArguments(tc.function.arguments) if (!parsed.ok) return false try { return sysTool.parallelSafe(parsed.value) === true } catch { return false } } const executeToolCalls = async ( toolCalls: readonly ToolCall[], registry: ToolRegistry, now: number, workingAgentId: AgentId, ctx?: ToolContext, hookSystem?: import('./hooks/hook-system.js').HookSystem, ): Promise<{ turns: ToolTurn[]; errorCount: number }> => { const turns: ToolTurn[] = new Array(toolCalls.length) let errorCount = 0 // parallelSafe 调用单独成桶(不占用 category 桶的并发槽);其余按 category 分组。 const parallelSafeItems: Array<{ idx: number; tc: ToolCall }> = [] const byCategory = new Map>() for (let idx = 0; idx < toolCalls.length; idx++) { const tc = toolCalls[idx]! if (isParallelSafeCall(tc, registry)) { parallelSafeItems.push({ idx, tc }) continue } const category = registry.getToolCategory(tc.function.name) const group = byCategory.get(category) if (group) { group.push({ idx, tc }) } else { byCategory.set(category, [{ idx, tc }]) } } const runBatch = async (batch: ReadonlyArray<{ idx: number; tc: ToolCall }>): Promise => { const results = await Promise.all( batch.map(async ({ idx, tc }): Promise<{ idx: number; turn: ToolTurn }> => { const turn = await executeSingleTool(tc, registry, now, workingAgentId, ctx, hookSystem) return { idx, turn } }), ) for (const { idx, turn } of results) { turns[idx] = turn if (turn.isError === true) errorCount += 1 } } // 并行安全桶:全部同时执行,不受 category 上限限制。 if (parallelSafeItems.length > 0) { await runBatch(parallelSafeItems) } // 普通 category 桶:每组按自己的并发上限切批执行。 for (const [category, items] of byCategory) { const limit = CONCURRENCY_LIMITS[category] for (let i = 0; i < items.length; i += limit) { await runBatch(items.slice(i, i + limit)) } } return { turns, errorCount } } // 执行单个工具调用(解析参数 → PreToolUse hook → execute → PostToolUse hook)。 // 从 executeToolCalls 抽出,使分桶逻辑与单工具执行逻辑解耦。 async function executeSingleTool( tc: ToolCall, registry: ToolRegistry, now: number, workingAgentId: AgentId, ctx?: ToolContext, hookSystem?: import('./hooks/hook-system.js').HookSystem, ): Promise { const parsed = parseToolCallArguments(tc.function.arguments) if (!parsed.ok) { return { id: mintTurnId('tool'), role: 'tool', toolCallId: tc.id, isError: true, content: `The arguments for "${tc.function.name}" were not valid JSON; please re-issue the call with valid JSON arguments.`, sourceAgentId: workingAgentId, at: now, toolName: tc.function.name, } } try { // v0.19: emit PreToolUse hook — can block tool execution. if (hookSystem) { const allowed = await hookSystem.emitPreToolUse({ event: 'PreToolUse', toolName: tc.function.name, args: parsed.value, sessionId: ctx?.sessionId, agentId: workingAgentId, }) if (!allowed) { return { id: mintTurnId('tool'), role: 'tool', toolCallId: tc.id, isError: true, content: `Tool "${tc.function.name}" was blocked by a PreToolUse hook.`, sourceAgentId: workingAgentId, at: now, toolName: tc.function.name, args: parsed.value, } } } const toolStart = Date.now() const out = await registry.execute(tc.function.name, parsed.value, ctx) const duration = Date.now() - toolStart // v0.19: emit PostToolUse hook — observation point. if (hookSystem) { await hookSystem.emit('PostToolUse', { event: 'PostToolUse', toolName: tc.function.name, args: parsed.value, result: out, duration, sessionId: ctx?.sessionId, agentId: workingAgentId, }) } return { id: mintTurnId('tool'), role: 'tool', toolCallId: tc.id, content: typeof out === 'string' ? out : JSON.stringify(out), sourceAgentId: workingAgentId, at: now, toolName: tc.function.name, args: parsed.value, } } catch (e) { const msg = e instanceof Error ? e.message : String(e) // 工具层(wrapTool)已处理错误兜底 + error turn 记录 + errorCount 计数。 // 失败路径不 emit PostToolUse:工具层已穿透状态机,hook 无需重复观察。 // 状态机通过 isError turn + errorRate guard 感知失败。 return { id: mintTurnId('tool'), role: 'tool', toolCallId: tc.id, isError: true, content: `Tool "${tc.function.name}" failed: ${msg}`, sourceAgentId: workingAgentId, at: now, toolName: tc.function.name, args: parsed.value, } } } // Compute this round's context size for the compression-layer trigger. // // 计数口径 = 实际发 LLM 的 wire format——经 turnToMessage 投影再序列化,排除 // id/at/sourceAgentId/toolName/args 等不进 prompt 的字段(直接 stringify turn 会把 // ToolTurn.args 双倍计入,M 层阈值失准);userTemplate 与 composePrompt 同口径, // user 回合已落 canonical 末尾时不再单列(实际请求里它作为 history 出现,不重复计)。 // // 去上游化(参考实现 context-amplifier 同源经验):触发用本地 DeepSeek 加权计数, // **不再读 provider 的 usage.prompt_tokens**。根因(2026-09-16 scanidx2 实测): // relay 在 prompt 缓存命中时只回报未缓存增量,usage.prompt_tokens 被钉死在远低于 // 真实上下文的值——真实 1.2M、usage 卡在 ~200K,内存层永远停在 M1,M3 持续压缩 // 永不启动,canonical 一路涨到 revive 溢出 400。本地计数对高重复语料会高估,这是 // 触发的**安全方向**(提前压,真实窗口绝不溢出)。 // 注:token guard(1M 硬限终止,guards.ts)仍用 provider usage,与本触发口径解耦—— // 若 guard 也改用本地高估值会在真实 ~290K 就误终止长会话。 const estimateContextTokens = (opts: IMLoopOptions): number => (opts.tokenCounter ?? DEEPSEEK_TOKEN_COUNTER).count( opts.systemPrompt + effectiveUserTemplate(opts.userTemplate) + opts.conversationMemory.turns().map(t => JSON.stringify(turnToMessageWithCounter(t, opts.tokenCounter))).join(''), ) /** v0.27 → v0.41 (b):估计口径与 compose 去重一致——userTemplate 非空即已落 canonical,不重复计。 */ const effectiveUserTemplate = (userTemplate: string): string => omitUserTemplatePart(userTemplate) ? '' : userTemplate // v0.43(2026-09-22 用户拍板,效仿 mimocode):上下文超过 512K token 后,每个用户 // 条目尾部追加固定记忆提醒——把模型掰向召回机制(state_query/ask_recall + mailbox_read), // 而不是让机制迁就模型。"窗口里没有 ≠ 不存在"是 MRCR-3M 实测的直接失分根因 // (模型把清单缺失误判为事实不存在,直接报 MISSING,0 次召回工具调用)。 export const MEMORY_REMINDER_MIN_TOKENS = 512_000 const buildMemoryReminder = (): string => '\n\n[记忆提醒] 当前会话上下文已超过 512K token,较早内容已被压缩为 #STAMP 信封或归档(M3)。' + '作答前如需历史事实:(1) 先查记忆层——state_query({layer:\'M1\'|\'M3\'}) 列出块、state_query({queryText:…}) 语义检索、ask_recall 问答式召回;' + '(2) 用 mailbox_read 读取未读邮件——drive 通知记录了哪些块被压缩/归档、对应的戳与召回方式;' + '(3) 不要凭印象作答,也不要把“当前窗口里没有”当成“不存在”——先召回查证,确认查不到才报告 unknown。' // Build this round's ToolContext. Pure — does not mutate the registry. const buildToolContext = ( opts: IMLoopOptions, ctxDatabus: Databus | MultiDatabus | undefined, directRecallLedger: DirectRecallLedger, ): ToolContext => { const ctx: ToolContext = { stateLine: opts.stateLine, agentId: opts.workingAgentId, databus: ctxDatabus ?? opts.databus, subAgentDepth: opts.subAgentDepth ?? 0, directRecallLedger, } if (opts.directRecallLimitTokens !== undefined) ctx.directRecallLimitTokens = opts.directRecallLimitTokens if (opts.archiveSourceStamps !== undefined) ctx.archiveSourceStamps = opts.archiveSourceStamps if (opts.archiveRawArchiveIds !== undefined) ctx.archiveRawArchiveIds = opts.archiveRawArchiveIds if (opts.isWikiAgent === true) ctx.isWikiAgent = true if (opts.sessionId !== undefined) ctx.sessionId = opts.sessionId // v0.27: 取消信号透传给工具层——阻塞型路径(审批门)据此与 abort 竞速。 if (opts.signal !== undefined) ctx.signal = opts.signal if (opts.requestHandler !== undefined) ctx.requestHandler = opts.requestHandler // v0.18: sub-agent tool policy for load_tools runtime filtering. if (opts.toolPolicy !== undefined) ctx.toolPolicy = opts.toolPolicy if (opts.allowedToolRefs !== undefined) ctx.allowedToolRefs = opts.allowedToolRefs if (opts.largeRecall !== undefined) ctx.largeRecall = opts.largeRecall if (opts.tokenCounter !== undefined) ctx.tokenCounter = opts.tokenCounter // v0.17: hooks propagate via ctx so run-subagent can pass them to child // loops (matching the v0.16 sessionId propagation pattern). if (opts.hooks !== undefined) ctx.hooks = opts.hooks return ctx } // Drive the background compression/eviction pipeline. Fire-and-forget with // a catch handler so a tick failure can never become an unhandled promise // rejection. Logged and swallowed. const fireDriveCoordinator = ( opts: IMLoopOptions, estimatedTokens: number, log: Logger, ): void => { if (!opts.driveCoordinator) return const dc = opts.driveCoordinator dc.tick({ contextTokens: estimatedTokens, conversation: opts.conversationMemory, databus: opts.databus, ...(opts.tokenCounter !== undefined ? { tokenCounter: opts.tokenCounter } : {}), memoryConfig: opts.memoryConfig ?? DEFAULT_MEMORY_CONFIG, }).catch((e: unknown) => { log.error('driveCoordinator.tick unhandled rejection', { err: e instanceof Error ? e.message : String(e), errStack: e instanceof Error ? e.stack : undefined, contextTokens: estimatedTokens, }) }) } // v0.17.x batch-1 fix: protocol-error retry backoff is racing against the // loop's AbortSignal. Previously `setTimeout(resolve, delay)` ignored an // already-aborted signal — the user had to wait the full backoff before // `throwIfAborted` could fire on the next await. This helper unblocks the // promise as soon as either the timer elapses or the signal aborts. // Root cause was the bare setTimeout not being signal-aware. The fix races // the two completion paths; no defensive try/catch or state flag. const waitWithAbort = (delayMs: number, signal: AbortSignal): Promise => { return new Promise((resolve) => { if (signal.aborted) { resolve(); return } const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort) resolve() }, delayMs) const onAbort = () => { clearTimeout(timer) signal.removeEventListener('abort', onAbort) resolve() } signal.addEventListener('abort', onAbort, { once: true }) }) } // (Abort handling is inlined at the call site where the abort must be // observed; a helper was tried and removed because only one place needed // the check, and inlining keeps the throw site obvious to readers.) // ---------------------------------------------------------------------------- // The loop. Behavior is identical to the pre-v0.17 monolith. // ---------------------------------------------------------------------------- export const runIMLoop = async (opts: IMLoopOptions): Promise => { const log = (opts.logger ?? defaultLogger).child({ component: 'im-loop', workingAgentId: opts.workingAgentId }) // v0.17.x P1-6: abort signal lifecycle. When caller passes a pre-aborted // signal, we honour it on the first await. The internal AbortController // stays open so existing callers that never opt into a signal are not // affected (throwIfAborted on a fresh controller is a no-op). const externalSignal = opts.signal const internalAc = new AbortController() // If the caller already aborted, propagate it to our internal controller // so every throwIfAborted below also fires. if (externalSignal?.aborted === true) internalAc.abort() // v0.17.x batch-1 fix: bridge externalSignal -> internalAc for the entire // turn. Without this listener, an external abort that fires AFTER the // initial check (e.g. another async path triggers `ac.abort()` partway // through the turn) would not propagate to internalAc and the loop's // throwIfAborted checks would see a stale un-aborted signal. Root cause: // the original implementation only handled the "aborted on entry" case // and forgot the "aborted mid-turn" case. The listener is removed when // the loop finishes so a single ToolRegistry instance can serve many // sequential runIMLoop calls without leaking listeners. const bridgeAbort = (): void => { internalAc.abort() } if (externalSignal !== undefined) { externalSignal.addEventListener('abort', bridgeAbort, { once: true }) } const acSignal = internalAc.signal const directRecallLedger = opts.directRecallLedger ?? createDirectRecallLedger() let state: State = 'Running' let metrics: Metrics = opts.initialMetrics ?? createMetrics() let turns = 0 let lastMetrics: Metrics | null = null const loopStart = Date.now() let protocolErrorRetries = 0 log.info('runIMLoop start', { model: opts.model, url: opts.url }) // v0.20 (ADR-025 #4): SessionStart hook — 会话开始信号 opts.hookSystem?.emit('SessionStart', { event: 'SessionStart', sessionId: opts.sessionId, agentId: opts.workingAgentId, toolName: undefined, args: undefined, result: undefined, duration: undefined, error: undefined, }) // Resolve contextual databus ONCE for the whole loop (v0.17 — closes a // MultiDatabus subscriber leak that existed in the per-round assembly). let ctxDatabus: Databus | MultiDatabus | undefined if (opts.ctxDatabus === undefined) { ctxDatabus = undefined } else if (Array.isArray(opts.ctxDatabus)) { if (opts.ctxDatabus.length === 1) { ctxDatabus = opts.ctxDatabus[0] as Databus } else if (opts.ctxDatabus.length > 1) { ctxDatabus = new MultiDatabus(opts.ctxDatabus as Databus[]) } else { ctxDatabus = undefined } } else { ctxDatabus = opts.ctxDatabus as Databus } // v0.27 修复(用户指出 + 双轮实证 USERS=0/4):user 回合此前从不进 canonical // 历史——用户输入只是当轮的 userTemplate 参数,用一次就丢(loop.ts 全文只有 // assistant:286 / tool:986 两个 appendCanonicalTurn 调用)。后果链:落盘缺 user // 行(真实 conversation.jsonl 实证)→ 新客户端/恢复/CLI/导出全缺用户消息 // (webapp 靠前端的本地乐观投影掩盖,即"多客户端不同步"局限的根源)→ // drive-coordinator 的 user 边界切块在主会话永不成立(findNextTaskBlock // 只扫 role==='user')。修复落在 loop 入口(compose 之前):把 userTemplate // 落成 canonical user 回合(memory + persistTurn),后续轮次的 history 自带 // 用户文本。消息组装顺序不变(system → history → userTemplate → …),状态机 // 顺序不动——本轮去重交给 composePrompt(见下),只是当轮不重复出现两次。 if (opts.userTemplate !== '') { // 记忆提醒(v0.43):>512K 后每个用户条目尾部追加提醒块。userTemplate 落 // canonical 之前估算本条目进入后的上下文规模(此刻它还没进 history,需单计)。 const entryTokens = estimateContextTokens(opts) + (opts.tokenCounter ?? DEEPSEEK_TOKEN_COUNTER).count(opts.userTemplate) const userTurn: ConversationTurn = { id: mintTurnId('user'), role: 'user', content: entryTokens > MEMORY_REMINDER_MIN_TOKENS ? opts.userTemplate + buildMemoryReminder() : opts.userTemplate, at: Date.now(), } appendCanonicalTurn(opts.conversationMemory, opts.databus, userTurn) if (opts.persistTurn) await opts.persistTurn(userTurn) } try { while (true) { // v0.17.x P1-6: abort is observed AFTER the first hook call (so the // hook has a chance to see the aborted signal in ctx) and after every // subsequent await boundary. When aborted, throwIfAborted raises a // DOMException AbortError caught by the outer try/catch and translated // to 'shell-terminated' IMLoopResult. `continue` paths re-enter the // loop here, so each iteration checks abort at least once. turns += 1 log.trace('round start', { turn: turns, state }) // 本轮的流式 turnId。必须全局唯一(§6.7 mintTurnId 纪律):信号关下游 // (webapp / CLI session-view)以 turnId 为流式条目的 key——若用 // `turn-${turns}` 这种每轮 runIMLoop 重置的短编号,跨轮撞车会把新轮的 // 流式文本合并进旧轮条目(items.length 不变 → 前端不滚动 → 用户看到 // "没有回应",真实事故 2026-09-08)。 const turnId = mintTurnId('turn') const estimatedTokens = estimateContextTokens(opts) // [HOOK 1] beforeShellCall — opt-in control flow intervention. const beforeDecision = await callHook(opts.hooks?.beforeShellCall, { turnId, stepNumber: turns, signal: acSignal, promptTokens: estimatedTokens, }, 'beforeShellCall', log) // (Abort handling: signal is passed to all hook ctx so hooks can // observe it. The throwIfAborted below is gated on no-synthetic so // a hook that intentionally terminates via block+synthetic is not // overridden by an external abort.) // KimiCode pattern (turn-step.ts:130): observe abort between awaits. // Skip when the hook supplied a syntheticResponse — the hook author // has already chosen the round's outcome, abort should not interfere. if (beforeDecision?.syntheticResponse === undefined) acSignal.throwIfAborted() // KimiCode pattern (turn-step.ts:125-127): block without replacement is // an error, not a continue. Throwing here lets the loop's try/catch // and finally cleanup run normally; the caller receives a clear // IMLoopResult with reason='guard-tripped' (the closest available // reason for "the hook author asked us to stop without supplying // anything to do"). if (beforeDecision?.block === true && beforeDecision.syntheticResponse === undefined) { state = 'Tripped' log.warn('beforeShellCall block=true without syntheticResponse; terminating', { turn: turns }) return { terminated: true, reason: 'guard-tripped', finalState: state, hits: [], turns, metrics, } } // v0.17.x P1-7: when a hook supplied a syntheticResponse, skip compose // + shellCall entirely — the hook owns the round's output. We still // run advanceElapsed + runGuards + appendTurn below so state-machine // invariants (metrics advance, guard evaluation, conversation write) // are preserved on synthetic rounds. const syntheticResult = beforeDecision?.syntheticResponse // --- Begin round body. The compose → shellCall → afterShellCall chain // is skipped when syntheticResult is provided. let finalResult: ShellCallResult if (syntheticResult !== undefined) { finalResult = syntheticResult } else { // v0.20 (ADR-025 Part 2): composePrompt 抽出为纯函数。 // 上下文组装顺序不变:system → afterSystem injections → history → userTemplate → afterUser injections → stateLine → tools → summaries → dynamicSchemas → skillText。 const parts = await composePrompt(opts, { state, metrics, turns, lastMetrics, loopStart, protocolErrorRetries }, estimatedTokens) const finalPrompt = compose(opts.registry, parts) const deps: ShellDeps = { config: opts.config, registry: opts.registry, state, metrics, streamChat: opts.streamChat, url: opts.url, model: opts.model, // v0.21: 流式增量旁路——loop 只负责补 turn 上下文(turnId), // 观察者语义由 opts.onStreamChunk 的注入方(delta-bridge)拥有。 ...(opts.onStreamChunk !== undefined ? { onStreamChunk: (chunk: import('../protocol/types.js').StreamChunk): void => opts.onStreamChunk!(turnId, chunk) } : {}), // v0.41 D19:严格交替 provider 的出站转写开关(provider 客观属性, // 装配层从 capabilities.strictAlternation 解析后逐轮透传)。 ...(opts.strictAlternation !== undefined ? { strictAlternation: opts.strictAlternation } : {}), } let result: ShellCallResult | undefined try { result = await shellCall(deps, finalPrompt) } catch (e) { if (e instanceof ShellTerminatedError) { log.warn('shell terminated', { state: e.state, hits: e.hits, turn: turns }) // Pass the error to afterShellCall so the hook can react even // though we are terminating here. await callHook(opts.hooks?.afterShellCall, { turnId, stepNumber: turns, signal: acSignal, error: e, }, 'afterShellCall', log) return { terminated: true, reason: 'shell-terminated', finalState: e.state, hits: e.hits, turns, metrics, } } if (e instanceof ProtocolError) { // [HOOK 2] afterShellCall — retry/terminate policy. const after = await callHook(opts.hooks?.afterShellCall, { turnId, stepNumber: turns, signal: acSignal, error: e, }, 'afterShellCall', log) if (after?.overrideTerminate) { return { terminated: true, reason: after.overrideTerminate.reason, finalState: state, hits: [...(after.overrideTerminate.hits ?? [])], turns, metrics, } } if (after?.retry === true) { // v0.17.x P1-3: hook retries are also subject to the global // retry cap — a malformed hook must not let the loop retry // forever. protocolErrorRetries += 1 if (protocolErrorRetries > MAX_PROTOCOL_ERROR_RETRIES) { log.warn('protocol error: hook retry cap reached', { turn: turns, retries: protocolErrorRetries - 1 }) return { terminated: true, reason: 'protocol-error', finalState: state, hits: [], turns, metrics, } } acSignal.throwIfAborted() const delay = after.retryDelayMs ?? 0 if (delay > 0) await waitWithAbort(delay, acSignal) continue } // Default: pre-v0.17 retry/terminate policy. Identical to before. if (!e.retriable) { log.warn('protocol error (non-retriable)', { err: e.message, turn: turns }) return { terminated: true, reason: 'protocol-error', finalState: state, hits: [], turns, metrics, } } protocolErrorRetries += 1 if (protocolErrorRetries > MAX_PROTOCOL_ERROR_RETRIES) { log.warn('protocol error: max retries exceeded', { err: e.message, turn: turns, retries: protocolErrorRetries - 1, }) return { terminated: true, reason: 'protocol-error', finalState: state, hits: [], turns, metrics, } } const delayMs = PROTOCOL_ERROR_RETRY_BASE_MS * Math.pow(2, protocolErrorRetries - 1) log.warn('protocol error: retrying', { err: e.message, turn: turns, retry: protocolErrorRetries, maxRetries: MAX_PROTOCOL_ERROR_RETRIES, delayMs, }) acSignal.throwIfAborted() await waitWithAbort(delayMs, acSignal) continue } log.error('shellCall threw unexpected error', { err: e instanceof Error ? e.message : String(e), errStack: e instanceof Error ? e.stack : undefined, turn: turns, }) throw e } // [HOOK 2b] afterShellCall with result — synthetic replacement path. const afterResult = await callHook(opts.hooks?.afterShellCall, { turnId, stepNumber: turns, signal: acSignal, result, }, 'afterShellCall', log) if (afterResult?.syntheticResult !== undefined) { result = afterResult.syntheticResult } if (afterResult?.overrideTerminate) { return { terminated: true, reason: afterResult.overrideTerminate.reason, finalState: state, hits: [...(afterResult.overrideTerminate.hits ?? [])], turns, metrics, } } finalResult = result! } // v0.20 (ADR-025 #4): TurnEnd hook — 单轮 LLM 调用完成信号(协议 done 穿透) opts.hookSystem?.emit('TurnEnd', { event: 'TurnEnd', sessionId: opts.sessionId, agentId: opts.workingAgentId, toolName: undefined, args: undefined, result: finalResult.response.choices[0]?.message?.content ?? null, duration: Date.now() - loopStart, error: undefined, }) metrics = advanceElapsed(finalResult.updatedMetrics, loopStart, Date.now()) lastMetrics = metrics log.trace('round shellCall ok', { turn: turns, elapsedMs: metrics.elapsedMs, stepCount: metrics.stepCount, lastRequestTokens: metrics.lastRequestTokens, }) // Inspect guard hits (re-run after elapsed advance — closes the // last-round time-guard blind spot). const hits = runGuards(metrics, opts.config) // [HOOK 3] afterGuards — can forceTerminate. KimiCode pattern: the // hook returns a boolean decision, not a state mutation. The state // field must follow the actual semantic — guard-tripped only when // guards actually hit; otherwise Running (a forceTerminate without // hits means "we agreed to stop" not "the machine tripped"). const afterGuardsResult = await callHook(opts.hooks?.afterGuards, { turnId, stepNumber: turns, signal: acSignal, hits, }, 'afterGuards', log) if (afterGuardsResult?.forceTerminate === true) { const reason = afterGuardsResult.reason ?? 'guard-tripped' // v0.17.x P0-2: state tracks actual lifecycle, not the hook's // desire. Only hits.length > 0 marks the shell as Tripped. state = hits.length > 0 ? 'Tripped' : 'Running' log.warn('forceTerminate by afterGuards hook', { turn: turns, reason, hits: hits.length }) return { terminated: true, reason, finalState: state, hits, turns, metrics, } } if (hits.length > 0) { state = 'Tripped' log.warn('guard tripped', { turn: turns, hits, finalMetrics: metrics }) return { terminated: true, reason: 'guard-tripped', finalState: state, hits, turns, metrics, } } // v0.20 (ADR-025 Part 2): finalizeRound 抽出为纯函数。 // 职责:appendAssistantTurn + persistTurn(一轮结束后的持久化)。 await finalizeRound(finalResult, opts, { state, metrics, turns, lastMetrics, loopStart, protocolErrorRetries }, estimatedTokens, log) // v0.30 (B1/B2/B3): 每轮 finalize 后统一 fire 一次压缩调度——不再只在 // 工具轮末或 block-continue 路径触发。这一处覆盖全部轮型:工具轮、 // 纯文本 completed 轮(会话收尾也能触发压缩)、synthetic 轮。 // 喂值去上游化:用本轮 estimatedTokens(轮初对实际上 wire 的 canonical 做的 // 本地 DeepSeek 加权计数 = 本轮真正发出去的那份内容的规模),**不再读 // metrics.lastRequestTokens**——relay 缓存命中时低报 usage 会把内存层钉死在 M1 // (根因见 estimateContextTokens 注释)。estimatedTokens 恰是"本轮请求的规模", // 正是层判定该用的口径。 fireDriveCoordinator(opts, estimatedTokens, log) if (finalResult.toolCalls.length === 0) { // [HOOK 6] beforeComplete — v0.41 goal 模式的唯一状态机接缝。 // 位置承重:必须在 finalizeRound(assistant 回合已 append + persist)与 // fireDriveCoordinator(本轮压缩已 fire)之后,所以 hook 看到的是本轮 // 完整收尾后的 canonical,它的判定与后续改动都不会影响本轮的持久化。 // 返回 continueWith = 否决 completed 终止,续跑一轮。 const beforeComplete = await callHook(opts.hooks?.beforeComplete, { turnId, stepNumber: turns, signal: acSignal, lastRequestTokens: metrics.lastRequestTokens, conversationMemory: opts.conversationMemory, databus: opts.databus, tokenCounter: opts.tokenCounter, }, 'beforeComplete', log) if (beforeComplete?.continueWith !== undefined) { // 追加合成 user 回合。写法对齐本文件 loop 入口的 userTemplate 规范化 // (v0.27)——那是全库唯一另一处往 canonical 写 user 回合的生产路径。 // idPrefix 由 hook 生产方拥有(mem- / compaction-note- / sys- 同惯例), // 下游据此区分真实用户输入与 harness 生成的续跑。 // 下一轮 composePrompt 经既有 history 投影(:244)自动带上 wire, // compose 层零改动;omitUserTemplatePart 的 role+content 去重不受影响。 const continuation: ConversationTurn = { id: mintTurnId(beforeComplete.continueWith.idPrefix), role: 'user', content: beforeComplete.continueWith.content, at: Date.now(), } appendCanonicalTurn(opts.conversationMemory, opts.databus, continuation) if (opts.persistTurn) await opts.persistTurn(continuation) log.info('runIMLoop continuing via beforeComplete hook', { turns, idPrefix: beforeComplete.continueWith.idPrefix }) continue } log.info('runIMLoop completed', { turns, metrics }) return { terminated: true, reason: 'completed', finalState: state, hits: [], turns, metrics, } } // [HOOK 4] beforeToolExecution — block / synthetic results. const beforeToolsResult = await callHook(opts.hooks?.beforeToolExecution, { turnId, stepNumber: turns, signal: acSignal, toolCalls: finalResult.toolCalls, }, 'beforeToolExecution', log) let toolResults: ToolTurn[] let errorCount: number // v0.18: ctx is created here so we can collect loaded dynamic schemas // after tool execution. load_tools writes to ctx._loadedDynamicTools. const ctx = buildToolContext(opts, ctxDatabus, directRecallLedger) if (beforeToolsResult?.block === true) { // v0.20 契约明确(ADR-025):block=true 无 syntheticToolResults = hook 编程错误。 // assistant turn(含 tool_calls)已在 HOOK 4 之前 commit,跳过 tool 执行 // → 下一轮 LLM 看到 tool_calls 无 tool response → provider 400 → 终止。 // hook 应返回 {block: true, syntheticToolResults: []} 表示"静默跳过"。 if (beforeToolsResult.syntheticToolResults !== undefined) { toolResults = [...beforeToolsResult.syntheticToolResults] errorCount = 0 } else { log.warn('beforeToolExecution block=true without syntheticToolResults; continuing', { turn: turns }) continue } } else if (beforeToolsResult?.syntheticToolResults !== undefined) { // Not blocked but synthetic results supplied — substitute. toolResults = [...beforeToolsResult.syntheticToolResults] errorCount = toolResults.filter(r => r.isError === true).length } else { const now = Date.now() const out = await executeToolCalls(finalResult.toolCalls, opts.registry, now, opts.workingAgentId, ctx, opts.hookSystem) toolResults = out.turns errorCount = out.errorCount } // v0.18: collect dynamic schemas loaded by load_tools in this round. // load_tools attaches schemas to ctx._loadedDynamicTools; the loop // accumulates them so the next round's compose includes them. const loadedDynamic = (ctx as Record)['_loadedDynamicTools'] as | Array<{ source: DynamicToolSource; tools: OpenAITool[] }> | undefined if (loadedDynamic && loadedDynamic.length > 0) { if (!opts.dynamicSchemas) opts.dynamicSchemas = [] opts.dynamicSchemas.push(...loadedDynamic) } // [HOOK 5] afterToolExecution — transform results before append. // v0.30: ctx 携带 conversationMemory + databus 引用——工具级压缩 // (历史工具表折叠)经此 hook 修改 canonical;两个引用是可选字段, // 不使用它们的既有 hook 零影响。 const afterToolsResult = await callHook(opts.hooks?.afterToolExecution, { turnId, stepNumber: turns, signal: acSignal, toolResults, errorCount, conversationMemory: opts.conversationMemory, databus: opts.databus, tokenCounter: opts.tokenCounter, }, 'afterToolExecution', log) if (afterToolsResult?.transformedResults !== undefined) { toolResults = [...afterToolsResult.transformedResults] errorCount = toolResults.filter(r => r.isError === true).length } if (errorCount > 0) { log.warn('tool errors in round', { turn: turns, errorCount, totalCount: toolResults.length, consecutiveToolErrors: metrics.consecutiveToolErrors + 1, }) } metrics = errorCount > 0 ? addToolError(metrics) : resetToolErrors(metrics) for (const tr of toolResults) { const { projectedTurn } = appendToolResultWithProjection( opts.conversationMemory, opts.databus, tr, opts.tokenCounter !== undefined ? { tokenCounter: opts.tokenCounter } : undefined, ) // v0.30 (2026-09-09 用户拍板,撤销 v0.24 "args 不落盘"): args 随落盘 // 全量保留——databus 消费方是 LLM 侧工具活动理解,args 是工具事件语义 // 核心;恢复(recovery)后内存 Databus 与热路径行为一致。敏感内容明文 // 落盘的取舍写入 docs/用户协议须提及.md,由用户自决。 // v0.44: conversation snapshot stores the same bounded projection the // model sees; the optional second argument preserves the complete // ToolTurn in databus.jsonl. if (opts.persistTurn) await opts.persistTurn(projectedTurn, tr) } } } catch (e) { // v0.17.x P1-6: an AbortError thrown by acSignal.throwIfAborted() — or // by a hook that aborted the signal mid-call — is reported as // 'shell-terminated' with the shell's normal failure state. Without // this catch, the AbortError would bubble to the caller as an // uncaught rejection, breaking the IMLoopResult contract. if (e instanceof Error && e.name === 'AbortError') { log.info('runIMLoop aborted', { turn: turns }) state = 'Tripped' return { terminated: true, reason: 'shell-terminated', finalState: state, hits: [], turns, metrics, } } throw e } finally { if (ctxDatabus instanceof MultiDatabus) ctxDatabus.close() if (externalSignal !== undefined) { externalSignal.removeEventListener('abort', bridgeAbort) } // v0.20 (ADR-025 #4): SessionEnd hook — 会话结束信号 opts.hookSystem?.emit('SessionEnd', { event: 'SessionEnd', sessionId: opts.sessionId, agentId: opts.workingAgentId, toolName: undefined, args: undefined, result: undefined, duration: undefined, error: undefined, }) } }