From 443e2bc509a8dfd03fc07c8b46f82f152282bf55 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:52:15 +0800 Subject: [PATCH 1/2] refactor(tools): shapeDispatchLog off the public registry surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responding to review on #661: a public method on the generic ToolRegistry service whose only caller is the run_code bridge was ad-hoc surface widening. The bridge now receives it as a registry-private capability closure in RunCodeBridgeOptions (the requireRuntime idiom, alongside the cap), the method is private, and it leaves the generated service catalog/API surfaces. The pattern is now named as a code smell where reviewers look: the packages/AGENTS.md capability-interface rule gains the inverse-smell clause (ceiling 660→675 — the list is at capacity and the clause needs one sentence), and dsh-code-review's capability-fit check tells reviewers to flag single-consumer public service methods and require the closure form. --- .agents/skills/dsh-code-review/SKILL.md | 2 +- docs/cordis-catalog/services.md | 12 +-------- packages/AGENTS.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 ------ packages/core/tools/src/code-mode.ts | 26 ++++++++++++++----- packages/core/tools/src/index.ts | 15 +++++++---- scripts/doc-budgets.manifest.json | 2 +- 7 files changed, 33 insertions(+), 34 deletions(-) diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 2c9fd86df5..47890519f2 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -30,7 +30,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - **Intent and seam contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any Agent Note, including errors, cancellation, ownership, and disposal. - **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns.md](../../../docs/defensive-patterns.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, ownership before reentry, complete detach cleanup, and quiescent disposal. -- **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package contract](../../../packages/AGENTS.md). +- **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package contract](../../../packages/AGENTS.md). Flag the inverse too: a new public method on a generic service (registry, session, agent) whose only caller is one internal consumer is an ad-hoc surface widening — require a private capability closure handed to that consumer at construction instead. - **Scope, ownership, and necessity:** map each abstraction, state machine, option, defensive copy, and compatibility path to its current contract, production consumer, and owning plugin or service. Challenge unrelated features and speculative generality, then test the PR's coherence against [the root contract](../../../AGENTS.md#conventions). - **Configuration and public choices:** ask what current-consumer evidence or prior art supports each default, public operation set, format, or imported external concept. Require an explicit choice or deferral when that evidence is absent. - **Model perspective:** inspect the exact prompts, tool schemas, results, and diagnostics the model receives across affected modes. Flag concepts outside the model's task, then verify stable text verbatim and dynamic behavior through snapshots or end-to-end coverage. diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ccb8e8f990..7ee7f89ac8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1830,16 +1830,6 @@ schemas(scope?: ScopeKey): ToolSchema[] */ executionMode(exec: ToolExecutionInput): ToolExecutionMode -/** - * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch - * and return the content the bridge should log on `tool/code-dispatch`. - * Contained: a throwing listener falls back to the unshaped content — log - * shaping must never fail the dispatch or lose the settle event. - * @param dispatch - the sub-dispatch identity and its default logged content. - * @returns the (possibly reshaped) content for the durable event. - */ -async shapeDispatchLog(dispatch: CodeDispatchLog): Promise - /** * Execute through pre-policy, guards, around-dispatch, post-policy, * definition-owned content finalization, and final notification. Tool and @@ -1857,7 +1847,7 @@ async shapeDispatchLog(dispatch: CodeDispatchLog): Promise async execute(exec: ToolExecutionInput): Promise ``` -Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) +Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) Source: [`packages/core/tools/src/index.ts:688`](../../packages/core/tools/src/index.ts) diff --git a/packages/AGENTS.md b/packages/AGENTS.md index bb7fdfa839..0e8c62841e 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -7,7 +7,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md). - **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). - **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence. -- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). +- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`). - **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service. - **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice. - **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 1d1c6c3b00..41c1fd9801 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -864,10 +864,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'executionMode(exec: ToolExecutionInput): ToolExecutionMode', jsDoc: '/**\n * Classify a pending call through the caller\'s visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */', }, - { - signature: 'async shapeDispatchLog(dispatch: CodeDispatchLog): Promise', - jsDoc: '/**\n * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch\n * and return the content the bridge should log on `tool/code-dispatch`.\n * Contained: a throwing listener falls back to the unshaped content — log\n * shaping must never fail the dispatch or lose the settle event.\n * @param dispatch - the sub-dispatch identity and its default logged content.\n * @returns the (possibly reshaped) content for the durable event.\n */', - }, { signature: 'async execute(exec: ToolExecutionInput): Promise', jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */', @@ -1439,10 +1435,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CodeBindingNamespace', declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record;\n errorClass?: CodeBindingErrorClass;\n}', }, - { - name: 'CodeDispatchLog', - declaration: 'export interface CodeDispatchLog {\n readonly exec: ToolExecution;\n readonly agent?: Agent;\n readonly subCallId: CallId;\n readonly name: string;\n readonly isError: boolean;\n readonly content: ContentBlock[];\n}', - }, { name: 'CodeJsonValue', declaration: 'export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | {\n [key: string]: CodeJsonValue;\n};', diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index f45bea489c..80c382915f 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -13,7 +13,7 @@ import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { JsonValue } from '@deepseek-ai/dsh-session' import { defineTool } from './schema.ts' import { TOOL_REGISTRY_SCHEDULER } from './index.ts' -import type { ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts' +import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { @@ -186,6 +186,20 @@ function renderValue(value: JsonValue): string { /** Canonical value returned by the outer Code Mode transport. */ type RunCodeOutput = { logs: string[]; result?: JsonValue } +/** + * Registry-private capabilities the bridge receives at construction — the + * `requireRuntime` idiom: operations only the owning registry can mint stay + * off its public service surface and flow here as closures instead. + */ +export interface RunCodeBridgeOptions { + /** Resolves `ctx.codeRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */ + requireRuntime: () => CodeRuntime + /** The run's overlap cap for parallel-classified sub-calls (the registry passes its validated `maxParallelSubCalls`). */ + maxParallel: number + /** Runs the contained `tools/code-dispatch-log` waterfall over one settled sub-dispatch (the registry's private invoker). */ + shapeDispatchLog: (dispatch: CodeDispatchLog) => Promise +} + /** * Build the `run_code` {@link ToolDefinition}: required `code` and * `description` parameters, executed through the dispatch bridge described @@ -194,13 +208,11 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue } * outside the filterable global/scoped capability layers. * @param registry - the owning registry (sub-calls go through its `execute`, * bindings cover its registered tools). - * @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud - * misconfiguration error (shared with the registry's assembly-time checks). - * @param maxParallel - the run's overlap cap for parallel-classified - * sub-calls (the registry passes its validated `maxParallelSubCalls`). + * @param options - the registry-private capabilities described above. * @returns the registry-ready definition. */ -export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime, maxParallel: number): ToolDefinition { +export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridgeOptions): ToolDefinition { + const { requireRuntime, maxParallel, shapeDispatchLog } = options return defineTool({ name: RUN_CODE_NAME, description: @@ -407,7 +419,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => // The durable copy may be reshaped (e.g. spilled to a preview + // locator) by the log-shaping waterfall; the program's value // and the model contract are untouched. - const logged = await registry.shapeDispatchLog({ + const logged = await shapeDispatchLog({ exec, agent, subCallId, name, isError: result.isError, // The registry deep-froze this projection at result // finalization; append snapshots the final copy again, so diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 5536cc4753..7b7b9ca353 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -727,7 +727,11 @@ export class ToolRegistry extends Service { // the filterable global/scoped capability layers. this.codeTransport = this.mode === 'native' ? undefined - : createRunCodeTool(this, () => this.requireCodeRuntime(), resolveMaxParallelSubCalls(config.maxParallelSubCalls)) + : createRunCodeTool(this, { + requireRuntime: () => this.requireCodeRuntime(), + maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls), + shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch), + }) ctx.systemPrompt.tools(context => this.wireSchemas(context.scope)) if (this.mode !== 'native') { ctx.systemPrompt.section({ @@ -982,11 +986,12 @@ export class ToolRegistry extends Service { * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch * and return the content the bridge should log on `tool/code-dispatch`. * Contained: a throwing listener falls back to the unshaped content — log - * shaping must never fail the dispatch or lose the settle event. - * @param dispatch - the sub-dispatch identity and its default logged content. - * @returns the (possibly reshaped) content for the durable event. + * shaping must never fail the dispatch or lose the settle event. Private: + * the ONE consumer is the `run_code` bridge this registry constructs, which + * receives it as a capability parameter (the `requireRuntime` idiom) — the + * waterfall, not this invoker, is the public extension seam. */ - async shapeDispatchLog(dispatch: CodeDispatchLog): Promise { + private async shapeDispatchLog(dispatch: CodeDispatchLog): Promise { try { return await this.ctx.waterfall( scopeTarget(this, dispatch.agent), 'tools/code-dispatch-log', dispatch, diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 3d0ce17051..f1d40380e5 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -6,6 +6,6 @@ "docs/defensive-patterns.md": 550, "docs/testing.md": 1100, "examples/AGENTS.md": 310, - "packages/AGENTS.md": 660, + "packages/AGENTS.md": 675, "packages/README.md": 835 } From 0b797a776f396b32ec9ba020a7b4a3cb66198e63 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:53:47 +0800 Subject: [PATCH 2/2] docs: note the private capability-closure shape for the dispatch-log invoker --- .../feature/2026-07-26-code-dispatch-log-spill.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-26-code-dispatch-log-spill.md | 2 +- .../feature/2026-07-26-code-dispatch-log-spill.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml index b00bff1000..dd94eb6cd5 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-26-code-dispatch-log-spill.md: 65af7808c493867cb13042a4f169ffdf05eb4538 -2026-07-26-code-dispatch-log-spill.zh.md: e1293e62f9de9860300428c5c0d25c5404dc76f9 +2026-07-26-code-dispatch-log-spill.md: eee8fb73b3f1ddba0a2da3ad5a9d2d4417d5951c +2026-07-26-code-dispatch-log-spill.zh.md: 664a2aefcfef198d56809c289e10827a8084a06a diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md index 65af7808c4..eee8fb73b3 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md @@ -14,7 +14,7 @@ Since the full-content dispatch logging landed, a `run_code` program that reads **A log-shaping waterfall on the registry, and the spill policy as its first listener.** -- **Seam**: `tools/code-dispatch-log` — a scope-filtered waterfall the bridge runs (via `registry.shapeDispatchLog`, contained: a throwing listener falls back to the unshaped content, with total error formatting so a hostile thrown value cannot escape the containment) over each settled sub-dispatch before appending `tool/code-dispatch`. The payload (`CodeDispatchLog`) carries the outer execution, the hoisted `agent` routing key, the sub-call identity, and the default content — the RENDERED result projection a native `tool/result` would carry (the program itself received the structured `value`). Only the durable copy is shapeable; the model sees neither. Shaping runs OFF the program path as tracked side work, but bounded: past `maxParallelSubCalls` pending log tasks the ordered commit lane holds, so a slow spill backend backpressures the run instead of accumulating unbounded pending I/O; run settlement still drains every task inside the open turn. +- **Seam**: `tools/code-dispatch-log` — a scope-filtered waterfall the bridge runs (via the registry's PRIVATE `shapeDispatchLog` invoker, handed to the bridge as a capability closure in `RunCodeBridgeOptions` — the waterfall is the public seam, the invoker never widens the service surface; contained: a throwing listener falls back to the unshaped content, with total error formatting so a hostile thrown value cannot escape the containment) over each settled sub-dispatch before appending `tool/code-dispatch`. The payload (`CodeDispatchLog`) carries the outer execution, the hoisted `agent` routing key, the sub-call identity, and the default content — the RENDERED result projection a native `tool/result` would carry (the program itself received the structured `value`). Only the durable copy is shapeable; the model sees neither. Shaping runs OFF the program path as tracked side work, but bounded: past `maxParallelSubCalls` pending log tasks the ordered commit lane holds, so a slow spill backend backpressures the run instead of accumulating unbounded pending I/O; run settlement still drains every task inside the open turn. - **Policy**: `dsh-spill-policy` registers a second arm on the new seam sharing the exact replacement pipeline of its model-facing arm (same `maxInlineBytes` cap, same preview + locator + within-cap invariant, same best-effort fallbacks), with the artifact labeled `dispatch` under the sub-call id. UIs and replay read the full text through the spill artifact exactly as they do for spilled native results, so the native-parity rendering story survives bounding. - **One deliberate asymmetry**: the model-facing arm skips `read` (the `read → spill → read again` loop); the dispatch-log arm bounds `read` sub-calls too — a log copy is not model context, so the loop cannot happen, and `read` is precisely the tool that produces huge logs. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md index e1293e62f9..664a2aefcf 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md @@ -14,7 +14,7 @@ Status: implemented **在注册表上增设一个日志整形 waterfall(瀑布式事件),spill 策略作为其第一个监听器。** -- **Seam**:`tools/code-dispatch-log`,一个按作用域过滤的 waterfall,由桥接层在追加 `tool/code-dispatch` 之前对每个已结算的子分发运行(经由 `registry.shapeDispatchLog`,且故障被兜住:监听器抛出异常时回退到未整形的内容,并用全防御的错误格式化确保恶意抛出值无法逃出兜底)。载荷(`CodeDispatchLog`)携带外层执行、提升出来的 `agent` 路由键、子调用标识与默认内容——即原生 `tool/result` 所载的渲染后结果投影(程序本身收到的是结构化 `value`)。可整形的只有持久副本;模型两者都看不到。整形作为被跟踪的旁路工作在程序路径之外运行,但有界:待处理日志任务超过 `maxParallelSubCalls` 时有序提交车道会暂停,因此慢速 spill 后端会对整个 run 施加背压,而不是无限累积待完成 I/O;run 结算仍会在开放轮次内排空全部任务。 +- **Seam**:`tools/code-dispatch-log`,一个按作用域过滤的 waterfall,由桥接层在追加 `tool/code-dispatch` 之前对每个已结算的子分发运行(经由注册表的私有 `shapeDispatchLog` 调用器——作为能力闭包经 `RunCodeBridgeOptions` 交给桥接层;waterfall 才是公开 seam,调用器绝不加宽服务表面。故障被兜住:监听器抛出异常时回退到未整形的内容,并用全防御的错误格式化确保恶意抛出值无法逃出兜底)。载荷(`CodeDispatchLog`)携带外层执行、提升出来的 `agent` 路由键、子调用标识与默认内容——即原生 `tool/result` 所载的渲染后结果投影(程序本身收到的是结构化 `value`)。可整形的只有持久副本;模型两者都看不到。整形作为被跟踪的旁路工作在程序路径之外运行,但有界:待处理日志任务超过 `maxParallelSubCalls` 时有序提交车道会暂停,因此慢速 spill 后端会对整个 run 施加背压,而不是无限累积待完成 I/O;run 结算仍会在开放轮次内排空全部任务。 - **策略**:`dsh-spill-policy` 在新 seam 上注册第二个分支,与其面向模型的分支共用一模一样的替换流水线(同样的 `maxInlineBytes` 上限、同样的预览 + 定位符 + 不超上限不变式、同样的尽力而为回退),产物以 `dispatch` 为标签,记在子调用 id 名下。UI 与回放通过 spill 产物读取全文,方式与读取被 spill 的原生结果完全相同,因此与原生同等保真的渲染在施加边界之后依然成立。 - **一处有意的不对称**:面向模型的分支跳过 `read`(避免 `read → spill → read again` 循环);分发日志分支则连 `read` 子调用也施加边界:日志副本不是模型上下文,该循环因此不可能发生,而 `read` 恰恰是会产生巨大日志的那个工具。