From 4ce32807e1b4faba5947e903b4be041dfbccc648 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 11:12:05 +0800 Subject: [PATCH 1/5] Fix async injection tool-result ordering --- docs/architecture.md | 2 +- docs/cordis-catalog/events.md | 26 +++++------ docs/event-producer-consumer.md | 26 +++++------ .../2026-06-15-turn-enclosure-invariant.md | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 43 +++++++++++++------ packages/core/agent-loop/src/loop.ts | 6 +++ packages/core/agent-loop/tests/loop.spec.ts | 41 ++++++++++++++---- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 9 ++-- 10 files changed, 104 insertions(+), 55 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b17c3302d8..79f9819ab4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,7 +96,7 @@ forever: The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). -Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. +Context that arrives while tool results are pending—including asynchronous `agent.inject()` notices and post-tool `additionalContext`—lands after the complete result batch so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. ### Failure Boundaries diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4af431f8c0..6a18fbd359 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:139`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:140`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:283`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -59,7 +59,7 @@ Awaited serial checkpoint for session-surface mutation after prompt assembly and Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:203`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -71,7 +71,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -83,7 +83,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -95,7 +95,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:225`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -107,7 +107,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:240`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -119,7 +119,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -131,7 +131,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:158`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -143,7 +143,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:251`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -155,7 +155,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:261`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -167,7 +167,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:271`](../../packages/core/agent/src/types.ts) ## `approval/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index bdf1c6e320..e08ab61b81 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,19 +7,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:140`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:149`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:203`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:213`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:168`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:225`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:240`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:158`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:251`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:261`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:271`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index e55cd0853e..5ffdedba18 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -18,7 +18,7 @@ In case 2, if the injected `context/message` is the last event before a flush/di **Every session event lives inside a turn** — between a `turn/start` and its matching `turn/end`. Concretely: - The loop appends queued `user/message` events **after** `turn/start` (inside the turn), not before it. `turn/end` is therefore owed the moment those messages are recorded, and the existing finalizer guarantees it. -- An `agent.inject()` made while the agent is **running** appends its `context/message` into the already-open turn (unchanged). +- An `agent.inject()` made while the agent is **running** joins the already-open turn. If assistant tool calls are awaiting results, the accepted context waits in arrival order and appends after the complete result batch so its user-role message cannot split provider tool pairing. - An `agent.inject()` made while **idle** wraps its `context/message` in a one-shot turn: `turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`. A new `injection` variant joins the merge-extensible `TurnTriggerMap`. - The loop derives the next turn number from the log each iteration (`lastTurnNumber(session) + 1`) instead of keeping a private counter, so an idle injection's one-shot turn cannot collide with the next real turn's number. - The `dsh-invariants` plugin **enforces** the invariant in dev: a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError`. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 91ed9d5049..3eb1d0b8f6 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -44,7 +44,7 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re - `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary. +`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while assistant tool calls await results stays in a FIFO until the complete result batch is logged, keeping provider tool messages contiguous. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index a2288c65b7..c45cddda84 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -12,7 +12,7 @@ import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek- import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' +import { isToolPairingBalanced, snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' import { Inbox, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' @@ -149,6 +149,8 @@ export class ReactLoopAgent implements Agent { * this set before the lifecycle unregisters the agent or detaches its session. */ private pendingIdleFlushes = new Set>() + /** Open-turn injections waiting for the active assistant tool-call batch to close. */ + private deferredInjections: InboxMessage[] = [] constructor( private loopCtx: Context, @@ -189,12 +191,11 @@ export class ReactLoopAgent implements Agent { } /** - * Accept one public send/steer payload as the exact detached record shared by - * the live notification and inbox. Lossless-JSON materialization reads every - * nested field once; deep freeze prevents an observer from rewriting queued - * work before the loop drains it. + * Accept one public message payload as a detached record. Lossless-JSON + * materialization reads every nested field once; deep freeze prevents later + * caller mutation before an inbox or deferred-injection queue drains it. */ - private acceptInboxMessage(content: ContentBlock[], options?: SendOptions): InboxMessage { + private acceptMessage(content: ContentBlock[], options?: SendOptions): InboxMessage { const source = this.resolveSource(options) const accepted = snapshotJsonValue({ content, source }) if (accepted === undefined) { @@ -210,7 +211,7 @@ export class ReactLoopAgent implements Agent { send(content: ContentBlock[], options?: SendOptions): void { this.assertNotDisposed() - const accepted = this.acceptInboxMessage(content, options) + const accepted = this.acceptMessage(content, options) this.#inbox.enqueue(accepted) const info = { source: accepted.source, steering: false } as const agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) @@ -219,7 +220,7 @@ export class ReactLoopAgent implements Agent { steer(content: ContentBlock[], options?: SendOptions): void { this.assertNotDisposed() if (this._status !== 'running') { this.send(content, options); return } - const accepted = this.acceptInboxMessage(content, options) + const accepted = this.acceptMessage(content, options) this.#inbox.steer(accepted) const info = { source: accepted.source, steering: true } as const agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) @@ -227,14 +228,21 @@ export class ReactLoopAgent implements Agent { inject(content: ContentBlock[], options?: SendOptions): void { this.assertNotDisposed() - const source = this.resolveSource(options) if (isTurnOpen(this.session)) { - // A turn is open in the LOG (decided from the log, not agent status — - // status can be `running` with no turn open): the context/message is - // turn-enclosed by that turn, so append it directly. - this.session.append('context/message', { content, source }, { surfaceOp: 'append' }) + const accepted = this.acceptMessage(content, options) + // Provider protocols require every assistant tool-call batch to be + // followed only by its tool results. Queue arbitrary asynchronous context + // until the tail cut is balanced; an existing queue preserves FIFO in the + // narrow window after the last result and before the loop drains it. + if (this.deferredInjections.length > 0 + || !isToolPairingBalanced(this.session.surface.nodes, this.session.events, null)) { + this.deferredInjections.push(accepted) + return + } + this.session.append('context/message', accepted, { surfaceOp: 'append' }) return } + const source = this.resolveSource(options) // No turn open: wrap the injection in a one-shot turn so every event stays // turn-enclosed (the durability/replay boundary is the turn). const turn = lastTurnNumber(this.session) + 1 @@ -272,6 +280,14 @@ export class ReactLoopAgent implements Agent { } } + /** Append deferred open-turn injections after the loop closes a tool-result batch. */ + private drainDeferredInjections(): void { + const pending = this.deferredInjections.splice(0) + for (const accepted of pending) { + this.session.append('context/message', accepted, { surfaceOp: 'append' }) + } + } + cancel(reason?: string): void { // Arm only for current work; an idle marker would cancel the next prompt. if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) { @@ -336,6 +352,7 @@ export class ReactLoopAgent implements Agent { isCancelled: () => this.cancelRequested, cancelReason: () => this.cancelReason, clearCancel: () => { this.cancelRequested = false }, + drainDeferredInjections: () => { this.drainDeferredInjections() }, // Pre-step cancellation re-parks without emitting a status transition. settleIdle: () => { this.settleIdleWaiters() }, }) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 18eca40cc1..965b2353c2 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -85,6 +85,8 @@ export interface LoopHandle { clearCancel(): void /** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */ settleIdle(): void + /** Append context that arrived while an assistant tool-call batch was awaiting its results. */ + readonly drainDeferredInjections: () => void } /** @@ -349,6 +351,10 @@ async function runTurn( break } + // A successful tool step has committed its complete result batch. Context + // accepted while that batch was pending can now join the open turn. + if (stepOutcome.hadToolCalls) handle.drainDeferredInjections() + // Preserve max-token completion unless a later disposal, abort, or error wins. const stepReason = stepFinishReason(stepOutcome.finish) if (stepReason) reason = stepReason diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index fb686928b1..851a4e63b9 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -383,22 +383,25 @@ describe('agent loop', () => { expect(flat).toContain('') }) - it('inject() while running appends into the open turn (no extra synthetic turn)', async () => { + it('defers inject() during tool execution until after the tool result', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'noticer', {}, 'calling'), textResponse('done'), ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // A tool that injects mid-execution: at this point the agent is running, so - // inject must append the context/message into the ALREADY-open turn rather - // than wrap it in its own one-shot turn. + let visibleDuringTool = false ctx.tools.register(defineTool({ name: 'noticer', description: 'injects a notice', parameters: {}, async execute() { - agent.inject([{ type: 'text', text: 'mid-turn notice' }], { source: { kind: 'plugin', plugin: 'x' } }) + await Promise.resolve() + const first = { type: 'text' as const, text: 'mid-turn notice' } + agent.inject([first], { source: { kind: 'plugin', plugin: 'x' } }) + first.text = 'mutated after inject' + agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } }) + visibleDuringTool = agent.session.events.some(e => e.type === 'context/message') return [{ type: 'text', text: 'ok' }] }, })) @@ -406,13 +409,35 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // Exactly ONE turn ran (no synthetic injection turn), and the mid-turn - // context/message sits inside it. + expect(visibleDuringTool).toBe(false) + + // The injection stays in the open turn, but its user-role context cannot + // split the assistant tool call from the provider's tool-result message. const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') expect(turnStarts).toHaveLength(1) const ts0 = turnStarts[0]! expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message') - expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true) + const result = agent.session.events.find(e => e.type === 'tool/result')! + const contexts = agent.session.events.filter(e => e.type === 'context/message') + expect(contexts).toHaveLength(2) + expect(result.seq).toBeLessThan(contexts[0]!.seq) + expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : [])) + .toEqual([ + { type: 'text', text: 'mid-turn notice' }, + { type: 'text', text: 'second notice' }, + ]) + + const secondRequest = adapter.requests[1]!.messages + const resultIndex = secondRequest.findIndex(message => + message.content.some(block => block.type === 'tool-result')) + const contextIndexes = secondRequest.flatMap((message, index) => + message.content.some(block => block.type === 'text' + && (block.text.includes('mid-turn notice') || block.text.includes('second notice'))) + ? [index] + : []) + expect(resultIndex).toBeGreaterThanOrEqual(0) + expect(contextIndexes).toHaveLength(2) + expect(contextIndexes.every(index => index > resultIndex)).toBe(true) }) it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 5639f30daf..ed42741cc1 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -41,7 +41,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle -- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) +- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. While a turn is open it joins that turn, deferring FIFO behind any pending tool-result batch so provider pairing remains contiguous; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 3aad65a70e..efe66693bf 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -103,10 +103,11 @@ export interface Agent { steer(content: ContentBlock[], options?: SendOptions): void /** - * Append model-facing context without running the model. Idle injection uses - * a one-shot turn and durability checkpoint, while injection during an open - * turn joins it at the current log position. Disposal awaits idle checkpoints; - * flush failures are reported through `agent/error`, not thrown to the caller. + * Append detached model-facing context without running the model. An open-turn + * injection joins at the current log position unless tool results are pending, + * in which case it waits FIFO until the complete result batch is logged. Idle + * injection uses a one-shot turn and durability checkpoint. Disposal awaits + * idle checkpoints; flush failures report through `agent/error`. */ inject(content: ContentBlock[], options?: SendOptions): void From 28144077f3da1a43cbb31639ef4a488ec5cca68e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 12:22:39 +0800 Subject: [PATCH 2/5] Fix deferred injection lifecycle --- docs/architecture.md | 2 +- .../2026-06-15-turn-enclosure-invariant.md | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 25 ++-- packages/core/agent-loop/src/loop.ts | 98 ++++++++-------- .../tests/contract-regressions.spec.ts | 109 ++++++++++++++++++ packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 8 +- 8 files changed, 184 insertions(+), 64 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 79f9819ab4..1b850da799 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,7 +96,7 @@ forever: The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). -Context that arrives while tool results are pending—including asynchronous `agent.inject()` notices and post-tool `additionalContext`—lands after the complete result batch so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. +Context that arrives while the current tool-call batch executes—including asynchronous `agent.inject()` notices and post-tool `additionalContext`—waits until execution settles and lands after every recorded result; successful batches keep call/result adjacency stable, while interrupted batches drain accepted context before the turn closes. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. ### Failure Boundaries diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index 5ffdedba18..a54f735219 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -18,7 +18,7 @@ In case 2, if the injected `context/message` is the last event before a flush/di **Every session event lives inside a turn** — between a `turn/start` and its matching `turn/end`. Concretely: - The loop appends queued `user/message` events **after** `turn/start` (inside the turn), not before it. `turn/end` is therefore owed the moment those messages are recorded, and the existing finalizer guarantees it. -- An `agent.inject()` made while the agent is **running** joins the already-open turn. If assistant tool calls are awaiting results, the accepted context waits in arrival order and appends after the complete result batch so its user-role message cannot split provider tool pairing. +- An `agent.inject()` made while the agent is **running** joins the already-open turn. While the current step executes assistant tool calls, accepted context waits in arrival order until that batch settles, then appends after every recorded result and before the turn closes even when execution is interrupted. - An `agent.inject()` made while **idle** wraps its `context/message` in a one-shot turn: `turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`. A new `injection` variant joins the merge-extensible `TurnTriggerMap`. - The loop derives the next turn number from the log each iteration (`lastTurnNumber(session) + 1`) instead of keeping a private counter, so an idle injection's one-shot turn cannot collide with the next real turn's number. - The `dsh-invariants` plugin **enforces** the invariant in dev: a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError`. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 3eb1d0b8f6..1ee596c285 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -44,7 +44,7 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re - `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while assistant tool calls await results stays in a FIFO until the complete result batch is logged, keeping provider tool messages contiguous. +`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index c45cddda84..24852aa9b7 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -12,7 +12,7 @@ import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek- import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { isToolPairingBalanced, snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' +import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' import { Inbox, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' @@ -149,6 +149,8 @@ export class ReactLoopAgent implements Agent { * this set before the lifecycle unregisters the agent or detaches its session. */ private pendingIdleFlushes = new Set>() + /** Whether the current step is executing an assistant tool-call batch. */ + private toolBatchActive = false /** Open-turn injections waiting for the active assistant tool-call batch to close. */ private deferredInjections: InboxMessage[] = [] @@ -231,11 +233,9 @@ export class ReactLoopAgent implements Agent { if (isTurnOpen(this.session)) { const accepted = this.acceptMessage(content, options) // Provider protocols require every assistant tool-call batch to be - // followed only by its tool results. Queue arbitrary asynchronous context - // until the tail cut is balanced; an existing queue preserves FIFO in the - // narrow window after the last result and before the loop drains it. - if (this.deferredInjections.length > 0 - || !isToolPairingBalanced(this.session.surface.nodes, this.session.events, null)) { + // followed only by its tool results. Historical interrupted batches do + // not own new context; only the currently executing batch may defer it. + if (this.toolBatchActive) { this.deferredInjections.push(accepted) return } @@ -288,6 +288,17 @@ export class ReactLoopAgent implements Agent { } } + /** Run one tool-call batch and drain its deferred context before resolving or rejecting. */ + private async withToolBatch(run: () => Promise): Promise { + this.toolBatchActive = true + try { + return await run() + } finally { + this.toolBatchActive = false + this.drainDeferredInjections() + } + } + cancel(reason?: string): void { // Arm only for current work; an idle marker would cancel the next prompt. if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) { @@ -352,7 +363,7 @@ export class ReactLoopAgent implements Agent { isCancelled: () => this.cancelRequested, cancelReason: () => this.cancelReason, clearCancel: () => { this.cancelRequested = false }, - drainDeferredInjections: () => { this.drainDeferredInjections() }, + withToolBatch: run => this.withToolBatch(run), // Pre-step cancellation re-parks without emitting a status transition. settleIdle: () => { this.settleIdleWaiters() }, }) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 965b2353c2..4a6eea8a99 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -85,8 +85,8 @@ export interface LoopHandle { clearCancel(): void /** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */ settleIdle(): void - /** Append context that arrived while an assistant tool-call batch was awaiting its results. */ - readonly drainDeferredInjections: () => void + /** Run an active tool-call batch and drain deferred context before resolving or rejecting. */ + readonly withToolBatch: (run: () => Promise) => Promise } /** @@ -327,7 +327,7 @@ async function runTurn( let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error } try { stepOutcome = await runStep( - ctx, events, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) + ctx, events, agent, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) } catch (error: unknown) { stepOutcome = { error: toError(error) } } finally { @@ -351,10 +351,6 @@ async function runTurn( break } - // A successful tool step has committed its complete result batch. Context - // accepted while that batch was pending can now join the open turn. - if (stepOutcome.hadToolCalls) handle.drainDeferredInjections() - // Preserve max-token completion unless a later disposal, abort, or error wins. const stepReason = stepFinishReason(stepOutcome.finish) if (stepReason) reason = stepReason @@ -468,6 +464,7 @@ async function runStep( ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, + handle: LoopHandle, turn: number, step: number, assembly: PromptAssembly, @@ -561,51 +558,54 @@ async function runStep( // Tool execution stays sequential; recheck abort around each normalized result. const toolCalls = message.content.filter(block => block.type === 'tool-call') - // Buffer context until all results are appended to preserve call/result adjacency. - const pendingContext: HookContext[] = [] - for (const call of toolCalls) { - /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) - const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments }) - let parsedArguments: unknown - try { - parsedArguments = call.arguments ? JSON.parse(call.arguments) : {} - } catch { - parsedArguments = call.arguments + if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish } + return handle.withToolBatch(async () => { + // Buffer context until all results are appended to preserve call/result adjacency. + const pendingContext: HookContext[] = [] + for (const call of toolCalls) { + /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ + if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) + const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments }) + let parsedArguments: unknown + try { + parsedArguments = call.arguments ? JSON.parse(call.arguments) : {} + } catch { + parsedArguments = call.arguments + } + // TODO(pre-tool-input-rewrite): Keep logged history and live presentation aligned; + // see docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md. + const result = await ctx.tools.execute({ + callId: call.id, + name: call.name, + arguments: parsedArguments, + agent, + signal, + }) + session.append('tool/result', { + turn, step, + // Preserve transcript pairing even if a post-execute listener returns another id. + callId: call.id, + content: result.content, + isError: result.isError, + ...result.error ? { error: result.error } : {}, + // Persist tool-owned presentation data for replay. + ...result.meta !== undefined ? { meta: result.meta } : {}, + }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) + if (result.additionalContext) pendingContext.push(result.additionalContext) + // The signal may flip while the tool is awaited. + /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) + /* v8 ignore stop */ } - // TODO(pre-tool-input-rewrite): Keep logged history and live presentation aligned; - // see docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md. - const result = await ctx.tools.execute({ - callId: call.id, - name: call.name, - arguments: parsedArguments, - agent, - signal, - }) - session.append('tool/result', { - turn, step, - // Preserve transcript pairing even if a post-execute listener returns another id. - callId: call.id, - content: result.content, - isError: result.isError, - ...result.error ? { error: result.error } : {}, - // Persist tool-owned presentation data for replay. - ...result.meta !== undefined ? { meta: result.meta } : {}, - }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) - if (result.additionalContext) pendingContext.push(result.additionalContext) - // The signal may flip while the tool is awaited. - /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) - /* v8 ignore stop */ - } - // Append buffered context after the complete result batch. - for (const context of pendingContext) { - agent.inject(context.content, { source: context.source }) - } + // Append buffered context after the complete result batch. + for (const context of pendingContext) { + agent.inject(context.content, { source: context.source }) + } - return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish } + return { hadToolCalls: true, finish: assembler.finish } + }) } function withoutToolCalls(message: Message): Message { diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 8130d4b893..1d951c16c2 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -139,6 +139,115 @@ describe('abort during tool execution ends the turn', () => { expect(adapter.requests).toHaveLength(1) // no follow-up model call expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }]) }) + + it('records context accepted before a tool-step abort in the same turn', async () => { + const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a-abort-injection'), { model: 'mock' }) + ctx.tools.register(defineTool({ + name: 'aborter', + description: '', + parameters: {}, + async execute() { + agent.inject([{ type: 'text', text: 'accepted before abort' }], { source: { kind: 'plugin', plugin: 'test' } }) + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') + return [{ type: 'text', text: 'done' }] + }, + })) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + const events = [...agent.session.events] + expect(events + .filter(event => event.type === 'tool/result' || event.type === 'context/message' + || event.type === 'step/end' || event.type === 'turn/end') + .map(event => event.type)) + .toEqual(['tool/result', 'context/message', 'step/end', 'turn/end']) + expect(events.find(event => event.type === 'context/message')?.data.content) + .toEqual([{ type: 'text', text: 'accepted before abort' }]) + }) + + it('drains deferred context before disposal reaches quiescence', async () => { + const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})]) + const ctx = await harness(adapter) + const started = Promise.withResolvers() + let agent!: ReactLoopAgent + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(AgentId('a-dispose-injection'), { model: 'mock' }) + }, { inject: ['agentLoop'] })) + ctx.tools.register(defineTool({ + name: 'waiter', + description: '', + parameters: {}, + async execute(_args, exec) { + agent.inject([{ type: 'text', text: 'accepted before disposal' }], { source: { kind: 'plugin', plugin: 'test' } }) + started.resolve(undefined) + const signal = exec.signal + if (!signal) throw new Error('tool execution signal is missing') + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + return [{ type: 'text', text: 'done' }] + }, + })) + + send(agent, 'go') + await started.promise + await fiber.dispose() + + expect(agent.session.events.find(event => event.type === 'context/message')?.data.content) + .toEqual([{ type: 'text', text: 'accepted before disposal' }]) + expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason) + .toEqual({ kind: 'disposed' }) + }) + + it('limits injection deferral to the current tool batch', async () => { + const adapter = new MockAdapter([ + [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'aborter', arguments: '{}' } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'second', arguments: '{}' } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] satisfies StreamChunk[], + textResponse('later turn'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a-historical-tool-pair'), { model: 'mock' }) + ctx.tools.register(defineTool({ + name: 'aborter', + description: '', + parameters: {}, + async execute() { + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') + return [{ type: 'text', text: 'done' }] + }, + })) + ctx.tools.register(defineTool({ + name: 'second', + description: '', + parameters: {}, + async execute() { + return [{ type: 'text', text: 'must not run' }] + }, + })) + + send(agent, 'leave an unmatched historical call') + await waitForIdle(ctx, agent) + ctx.on('agent/pre-step', (subject, turn) => { + if (subject === agent && turn === 2) { + agent.inject([{ type: 'text', text: 'new turn context' }], { source: { kind: 'plugin', plugin: 'test' } }) + } + }) + send(agent, 'start a text-only turn') + await waitForIdle(ctx, agent) + + expect(agent.session.events.find(event => event.type === 'context/message')?.data.content) + .toEqual([{ type: 'text', text: 'new turn context' }]) + expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context') + }) }) describe('steering from late extension points is never stranded', () => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index ed42741cc1..03b0957fbf 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -41,7 +41,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle -- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. While a turn is open it joins that turn, deferring FIFO behind any pending tool-result batch so provider pairing remains contiguous; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). +- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index efe66693bf..f1a704125a 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -104,10 +104,10 @@ export interface Agent { /** * Append detached model-facing context without running the model. An open-turn - * injection joins at the current log position unless tool results are pending, - * in which case it waits FIFO until the complete result batch is logged. Idle - * injection uses a one-shot turn and durability checkpoint. Disposal awaits - * idle checkpoints; flush failures report through `agent/error`. + * injection joins at the current log position unless the current tool batch is + * executing; then it waits FIFO until that batch settles and drains before turn + * close even when interrupted. Idle injection uses a one-shot turn and durability + * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`. */ inject(content: ContentBlock[], options?: SendOptions): void From c3d5568efd6a626ae3a3a585495d3113f35aac99 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 12:31:25 +0800 Subject: [PATCH 3/5] Document deferred injection ordering --- docs/core-data-structures/core.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 9a9a1706fc..7adda313a4 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -281,9 +281,14 @@ interface Agent { /** * Inject in-session context (file-change notices, skill content, cron - * notifications, …): appends a `context/message` session event the next model - * request sees at its chronological position, rendered as tagged synthetic - * context rather than a user prompt. Does not run the model. + * notifications, …): accepts context for a `context/message` session event + * the next model request sees, rendered as tagged synthetic context rather + * than a user prompt. Does not run the model. + * + * In an open turn, inject appends at the current log position except while + * the current tool-call batch executes: accepted context waits FIFO until the + * batch settles, then appends after every recorded result and before turn + * close even when execution is interrupted. * * Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn; * an inject while idle wraps its `context/message` in a one-shot `injection` From edfc4dc4ac8c9a5ae8c45cd2b87ef3e629685920 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 12:49:50 +0800 Subject: [PATCH 4/5] Preserve interrupted post-tool context --- docs/core-data-structures/tools.md | 9 +- .../feature/2026-06-30-interception-seams.md | 2 +- docs/tool-execution-pipeline.md | 4 +- packages/core/agent-loop/src/agent.ts | 18 +++- packages/core/agent-loop/src/loop.ts | 17 ++-- .../tests/contract-regressions.spec.ts | 85 +++++++++++++++++-- packages/core/tools/src/index.ts | 6 +- scripts/gen-doc-graphs.ts | 4 +- 8 files changed, 113 insertions(+), 32 deletions(-) diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 08c289e5e5..1aa20d5383 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -151,10 +151,11 @@ interface ToolExecutionResult { * NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part * of this call's `content` — `content`/`feedback` shape the tool RESULT, but * `additionalContext` is a SEPARATE `context/message`. A step can carry - * multiple tool calls, so the loop BUFFERS every call's `additionalContext` - * and appends them only AFTER all `tool/result`s for the step, keeping - * tool-call/result adjacency intact. Carried on the result purely to ferry it - * from `execute()` up to the loop's per-step buffer. + * multiple tool calls, so the loop accepts every call's `additionalContext` + * into the active-batch FIFO and appends it only when that batch settles. A + * successful batch places context AFTER all its `tool/result`s; an interrupted + * batch places it after every recorded result and before turn close. Carried + * on the result purely to ferry it from `execute()` up to that FIFO. */ additionalContext?: HookContext /** diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index ea371ad55a..303c78055d 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -36,7 +36,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li 1. **Open the turn before prompt policy.** A fully blocked batch becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. Every veto also records `prompt/blocked` with the original prompt and reason, so mixed batches retain blocked inputs. Allowed `additionalContext` is injected into the open turn. -2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended. +2. **Post-tool `additionalContext` enters the active-batch FIFO and appends when that batch settles.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`; the loop accepts each context into the same FIFO as asynchronous injections, then appends the FIFO after the complete result batch on success or after every recorded result before an interrupted turn closes. 3. **A forced `continue` `reason` is enqueued through the steering channel**, so the next step's top-of-loop drain records it as steering for the continued turn — next-*step* steering within the SAME turn, not a next-*turn* prompt (matching the existing `hasSteering` force-continue override). diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 51641d4655..0caf979efd 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -20,9 +20,9 @@ flowchart TD owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/code-dispatch"] post["tools/post-execute waterfall
accept, block, replace, add context"] final["tools/result synchronous notification
frozen authoritative outcome"] - context["Buffered additionalContext
context/message after all tool results"] + context["Batch-deferred additionalContext
context/message after recorded tool results"] toolResult["Session event: tool/result
single model-facing outcome"] - allResults["All calls in the step settled
and tool/result events recorded"] + allResults["Tool batch settled
recorded tool/result events complete"] presentResult["UI completed card
presentResult(args, result)"] model --> toolCall toolCall --> presentCall diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 24852aa9b7..82d381bcae 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent' +import type { AgentId, AgentOptions, AgentStatus, HookContext, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' @@ -288,11 +288,21 @@ export class ReactLoopAgent implements Agent { } } - /** Run one tool-call batch and drain its deferred context before resolving or rejecting. */ - private async withToolBatch(run: () => Promise): Promise { + /** + * Run one tool-call batch and drain its deferred context before settlement. + * The loop-owned acceptor remains valid after public disposal begins because + * the interrupted turn stays open until this batch settles. + */ + private async withToolBatch( + run: (acceptContext: (context: HookContext) => void) => Promise, + ): Promise { this.toolBatchActive = true + const acceptContext = (context: HookContext): void => { + const accepted = this.acceptMessage(context.content, { source: context.source }) + this.deferredInjections.push(accepted) + } try { - return await run() + return await run(acceptContext) } finally { this.toolBatchActive = false this.drainDeferredInjections() diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 4a6eea8a99..0c6ba7a4fa 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -85,8 +85,8 @@ export interface LoopHandle { clearCancel(): void /** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */ settleIdle(): void - /** Run an active tool-call batch and drain deferred context before resolving or rejecting. */ - readonly withToolBatch: (run: () => Promise) => Promise + /** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */ + readonly withToolBatch: (run: (acceptContext: (context: HookContext) => void) => Promise) => Promise } /** @@ -559,9 +559,7 @@ async function runStep( // Tool execution stays sequential; recheck abort around each normalized result. const toolCalls = message.content.filter(block => block.type === 'tool-call') if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish } - return handle.withToolBatch(async () => { - // Buffer context until all results are appended to preserve call/result adjacency. - const pendingContext: HookContext[] = [] + return handle.withToolBatch(async (acceptContext) => { for (const call of toolCalls) { /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) @@ -591,7 +589,9 @@ async function runStep( // Persist tool-owned presentation data for replay. ...result.meta !== undefined ? { meta: result.meta } : {}, }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) - if (result.additionalContext) pendingContext.push(result.additionalContext) + // Accept into the batch FIFO immediately; it remains deferred until every + // result settles and survives abort, cancellation, or disposal afterward. + if (result.additionalContext) acceptContext(result.additionalContext) // The signal may flip while the tool is awaited. /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -599,11 +599,6 @@ async function runStep( /* v8 ignore stop */ } - // Append buffered context after the complete result batch. - for (const context of pendingContext) { - agent.inject(context.content, { source: context.source }) - } - return { hadToolCalls: true, finish: assembler.finish } }) } diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 1d951c16c2..cc75943c4d 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' @@ -154,6 +154,13 @@ describe('abort during tool execution ends the turn', () => { return [{ type: 'text', text: 'done' }] }, })) + ctx.on('tools/post-execute', async (): Promise => ({ + kind: 'accept', + additionalContext: { + content: [{ type: 'text', text: 'accepted result context after abort' }], + source: { kind: 'plugin', plugin: 'test' }, + }, + })) send(agent, 'go') await waitForIdle(ctx, agent) @@ -163,9 +170,65 @@ describe('abort during tool execution ends the turn', () => { .filter(event => event.type === 'tool/result' || event.type === 'context/message' || event.type === 'step/end' || event.type === 'turn/end') .map(event => event.type)) - .toEqual(['tool/result', 'context/message', 'step/end', 'turn/end']) + .toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end']) + expect(events + .filter(event => event.type === 'context/message') + .map(event => event.data.content)) + .toEqual([ + [{ type: 'text', text: 'accepted before abort' }], + [{ type: 'text', text: 'accepted result context after abort' }], + ]) + }) + + it('records post-tool context when a later call aborts the batch', async () => { + const adapter = new MockAdapter([[ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'first', arguments: '{}' } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'aborter', arguments: '{}' } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] satisfies StreamChunk[]]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a-later-abort-context'), { model: 'mock' }) + ctx.tools.register(defineTool({ + name: 'first', + description: '', + parameters: {}, + async execute() { + return [{ type: 'text', text: 'first done' }] + }, + })) + ctx.tools.register(defineTool({ + name: 'aborter', + description: '', + parameters: {}, + async execute() { + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') + return [{ type: 'text', text: 'aborted' }] + }, + })) + ctx.on('tools/post-execute', async (exec, _result, next): Promise => { + if (exec.callId !== CallId('c1')) return next() + return { + kind: 'accept', + additionalContext: { + content: [{ type: 'text', text: 'accepted after first result' }], + source: { kind: 'plugin', plugin: 'test' }, + }, + } + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + const events = [...agent.session.events] + expect(events + .filter(event => event.type === 'tool/result' || event.type === 'context/message' + || event.type === 'step/end' || event.type === 'turn/end') + .map(event => event.type)) + .toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end']) expect(events.find(event => event.type === 'context/message')?.data.content) - .toEqual([{ type: 'text', text: 'accepted before abort' }]) + .toEqual([{ type: 'text', text: 'accepted after first result' }]) }) it('drains deferred context before disposal reaches quiescence', async () => { @@ -192,13 +255,25 @@ describe('abort during tool execution ends the turn', () => { return [{ type: 'text', text: 'done' }] }, })) + ctx.on('tools/post-execute', async (): Promise => ({ + kind: 'accept', + additionalContext: { + content: [{ type: 'text', text: 'accepted result context during disposal' }], + source: { kind: 'plugin', plugin: 'test' }, + }, + })) send(agent, 'go') await started.promise await fiber.dispose() - expect(agent.session.events.find(event => event.type === 'context/message')?.data.content) - .toEqual([{ type: 'text', text: 'accepted before disposal' }]) + expect(agent.session.events + .filter(event => event.type === 'context/message') + .map(event => event.data.content)) + .toEqual([ + [{ type: 'text', text: 'accepted before disposal' }], + [{ type: 'text', text: 'accepted result context during disposal' }], + ]) expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'disposed' }) }) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 988c6a0268..695a1e2ff1 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -238,8 +238,8 @@ export interface ToolExecutionResult { */ error?: ToolErrorInfo /** - * Model-facing context for the next request, separate from this tool result. - * The loop buffers it until all step results are logged, preserving pairing. + * Model-facing context for the next request, separate from this tool result. The loop + * accepts it into the active-batch FIFO, then appends after recorded results even if interrupted. */ additionalContext?: HookContext /** @@ -843,7 +843,7 @@ export class ToolRegistry extends Service { * its {@link PostToolDecision}: `accept` keeps the call successful (replacing * `content` when given), `block` turns it into an `isError` whose content is * the corrective `feedback`. Either decision may attach `additionalContext`, - * which is ferried on the returned result for the loop's per-step buffer. + * which is ferried on the returned result for the loop's active-batch FIFO. * Runs inside `execute`'s outer try/catch (a throwing listener → isError). */ private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise { diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index a6660f7cd3..a836b399e6 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -850,9 +850,9 @@ function renderToolPipeline(): string { ` owned["Tool-owned session events
${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`, ` post["${mermaidCode('tools/post-execute')} waterfall
accept, block, replace, add context"]`, ` final["${mermaidCode('tools/result')} synchronous notification
frozen authoritative outcome"]`, - ' context["Buffered additionalContext
context/message after all tool results"]', + ' context["Batch-deferred additionalContext
context/message after recorded tool results"]', ` toolResult["Session event: ${mermaidCode('tool/result')}
single model-facing outcome"]`, - ' allResults["All calls in the step settled
and tool/result events recorded"]', + ' allResults["Tool batch settled
recorded tool/result events complete"]', ' presentResult["UI completed card
presentResult(args, result)"]', ' model --> toolCall', ' toolCall --> presentCall', From 4bf36e4dd153e97d47118881e96134f494da4671 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 10:29:08 +0800 Subject: [PATCH 5/5] docs: condense tool-context lifecycle contract --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index ce3f4898a2..2637f09f71 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,7 +97,7 @@ forever: The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). -Context that arrives while the current tool-call batch executes—including asynchronous `agent.inject()` notices and post-tool `additionalContext`—waits until execution settles and lands after every recorded result; successful batches keep call/result adjacency stable, while interrupted batches drain accepted context before the turn closes. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. +Context accepted during tool execution—including async `agent.inject()` notices and post-tool `additionalContext`—waits for settlement, then follows every recorded result. Successful batches preserve call/result adjacency; interrupted batches drain that context before turn closure. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. ### Failure Boundaries