Просмотр исходного кода

Fix async injection tool-result ordering

Hypatia May 2 месяцев назад
Родитель
Сommit
4ce32807e1

+ 1 - 1
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
 

+ 13 - 13
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/*`
 

+ 13 - 13
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) |

+ 1 - 1
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`.

+ 1 - 1
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`)
 

+ 30 - 13
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<Promise<void>>()
+  /** 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() },
     })

+ 6 - 0
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

+ 33 - 8
packages/core/agent-loop/tests/loop.spec.ts

@@ -383,22 +383,25 @@ describe('agent loop', () => {
     expect(flat).toContain('<context source=\\"plugin\\">')
   })
 
-  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 () => {

+ 1 - 1
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`

+ 5 - 4
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