Download src/im/loop.ts from uos1231234/agent-shell: direct link, hf CLI and curl.
- Browser
- Download file 57.4 kB
-
https://huggingface.co/uos1231234/agent-shell/resolve/main/src/im/loop.ts
- Command line
-
hf download hf://uos1231234/agent-shell/src/im/loop.ts
-
curl -L -o loop.ts https://huggingface.co/uos1231234/agent-shell/resolve/main/src/im/loop.ts
57.4 kB
| // 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<StreamChunk> | |
| 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<unknown> | |
| 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<void> | |
| // 思维链落盘门控(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<PromptPart[]> { | |
| 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 | |
| : `<!-- when_to_use: ${ts.whenToUse} -->\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<void> { | |
| // 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<ToolCategory, Array<{ idx: number; tc: ToolCall }>>() | |
| 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<void> => { | |
| 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<ToolTurn> { | |
| 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<void> => { | |
| return new Promise<void>((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<IMLoopResult> => { | |
| 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<AfterShellCallContext, AfterShellCallResult>(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<AfterShellCallContext, AfterShellCallResult>(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<AfterShellCallContext, AfterShellCallResult>(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<string, unknown>)['_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, | |
| }) | |
| } | |
| } | |