Sfoglia il codice sorgente

fix(agent-loop): own queued message input

Tianyi Cui 2 mesi fa
parent
commit
a34801df4b

+ 14 - 14
docs/cordis-catalog/events.md

@@ -23,7 +23,7 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:307`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts)
 
 ### `agent/disposed` — emit
 
@@ -35,7 +35,7 @@ An agent was removed from the registry. The concrete AgentLoop lifecycle emits t
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:322`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:331`](../../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:596`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:608`](../../packages/core/agent/src/types.ts)
 
 ### `agent/pre-step` — serial
 
@@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the
 
 Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:428`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:440`](../../packages/core/agent/src/types.ts)
 
 ### `agent/prompt-submit` — waterfall
 
@@ -73,11 +73,11 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a
 
 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:446`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:458`](../../packages/core/agent/src/types.ts)
 
 ### `agent/queued` — emit
 
-A message entered the agent's inbox (queued or steering). `source` is the resolved source (defaults applied), not the caller's raw options.
+A message entered the agent's inbox (queued or steering). Content and the resolved source are the detached, deeply-frozen values retained by the inbox; the `info` wrapper is frozen too, so one listener cannot rewrite what another listener observes. `source` has defaults applied and is not the caller's raw options.
 
 ```ts cordis-catalog
 'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
@@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv
 
 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:350`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts)
 
 ### `agent/request` — waterfall
 
@@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove
 
 Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:475`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:487`](../../packages/core/agent/src/types.ts)
 
 ### `agent/session-prefix` — waterfall
 
@@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array —
 
 Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:527`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:539`](../../packages/core/agent/src/types.ts)
 
 ### `agent/session-start` — emit
 
@@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source`
 
 Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:371`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:383`](../../packages/core/agent/src/types.ts)
 
 ### `agent/status` — emit
 
@@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:336`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts)
 
 ### `agent/step-result` — waterfall
 
@@ -149,7 +149,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:542`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:554`](../../packages/core/agent/src/types.ts)
 
 ### `agent/turn-continuation` — waterfall
 
@@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:560`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:572`](../../packages/core/agent/src/types.ts)
 
 ### `agent/turn-stop` — serial
 
@@ -173,7 +173,7 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:579`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:591`](../../packages/core/agent/src/types.ts)
 
 ## `approval/*`
 

+ 11 - 2
docs/core-data-structures/core.md

@@ -267,12 +267,21 @@ interface Agent {
    */
   readonly ctx: Context
 
-  /** Queue a user message. Starts a turn when idle; otherwise waits for the next turn. */
+  /**
+   * Queue a user message. Starts a turn when idle; otherwise waits for the next
+   * turn. Content and the resolved source are accepted as one detached,
+   * deeply-frozen lossless-JSON record before notification or enqueue, so
+   * caller or `agent/queued` listener in-place mutation cannot change later
+   * log/model input. Throws synchronously when either value is not losslessly
+   * JSON-serializable; `agent/prompt-submit` may still return an explicit
+   * replacement.
+   */
   send(content: ContentBlock[], options?: SendOptions): void
 
   /**
    * Steer a running turn: content is injected between steps of the current
-   * turn. When idle, behaves like {@link send}.
+   * turn. Uses the same owned-value and synchronous-validation boundary as
+   * {@link send}; when idle, behaves exactly like that method.
    */
   steer(content: ContentBlock[], options?: SendOptions): void
 

+ 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:307`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:322`](../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:596`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
-| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:428`](../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:446`](../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:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:475`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
-| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:527`](../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:371`](../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), [`invariants`](../packages/support/invariants) |
-| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:336`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`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:542`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
-| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:560`](../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:579`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
+| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../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:608`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
+| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:440`](../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:458`](../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:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:487`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
+| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:539`](../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:383`](../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), [`invariants`](../packages/support/invariants) |
+| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`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:554`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
+| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:572`](../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:591`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
 | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:72`](../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:123`](../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:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |

+ 3 - 0
docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md

@@ -419,6 +419,7 @@ Capture does not imply uniform eager callback type-checking. Agent `setup` is ca
 |---|---|---|
 | Tool and `SubagentProvider` registration | Original callback receiver | Name, flags, schemas, scalar config |
 | Agent create/resume | Caller context, setup callback | IDs, options, session metadata and seed |
+| Agent send/steer | None | Content blocks and resolved message source |
 | Approval request | Agent and abort signal | Tool name, call ID, and reason |
 | Tool execution | Agent, signal, registry-minted parent token | Call identity and arguments |
 | Session append/load | Session identity | Header and event envelopes |
@@ -426,6 +427,8 @@ Capture does not imply uniform eager callback type-checking. Agent `setup` is ca
 
 Before agent setup can run, the concrete agent pins its accepted ID, options, and session and binds `ctx` once. Registry detach closures likewise close over their accepted keys instead of rereading mutable public fields.
 
+`send()` and running `steer()` resolve the message source once and materialize `{ content, source }` as one detached, deeply frozen lossless-JSON record before `agent/queued` or inbox insertion. The notification and FIFO share that accepted content and source; its metadata wrapper is frozen separately, so neither retained caller references nor an earlier notification listener can rewrite what a later listener, the session log, or the model sees. Invalid content or source throws synchronously without notification, enqueue, or loop wakeup; idle `steer()` delegates to the same `send()` boundary. The later `agent/prompt-submit` waterfall can still replace a queued prompt by returning new content; ownership forbids in-place mutation, not the explicit rewrite protocol.
+
 A stateful getter shows why validation and ownership must use the same capture:
 
 ```js

+ 1 - 1
packages/core/agent-loop/README.md

@@ -44,7 +44,7 @@ Agents listed in config are auto-created at startup. `cwd` applies only to fresh
 
 - `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 enable/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.
+`Inbox`, `runLoop`, and the instance-bound enable/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.
 
 ### Loop lifecycle (`loop.ts`)
 

+ 36 - 11
packages/core/agent-loop/src/agent.ts

@@ -12,8 +12,8 @@ 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 type { Session } from '@deepseek-ai/dsh-session'
-import { Inbox } from './inbox.ts'
+import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session'
+import { Inbox, type InboxMessage } from './inbox.ts'
 import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
 
 /** Agents whose rollback-covered publication enabled driving. */
@@ -213,6 +213,26 @@ export class ReactLoopAgent implements Agent {
     return options?.source ?? { kind: 'user' }
   }
 
+  /**
+   * 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.
+   */
+  private acceptInboxMessage(content: ContentBlock[], options?: SendOptions): InboxMessage {
+    const source = this.resolveSource(options)
+    const accepted = snapshotJsonValue({ content, source })
+    if (accepted === undefined) {
+      throw new TypeError('agent message content and source must be losslessly JSON-serializable')
+    }
+    return deepFreeze(accepted)
+  }
+
+  /** Reject a driving operation once teardown has synchronously closed the agent. */
+  private assertNotDisposed(): void {
+    if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
+  }
+
   /** Reject every driving verb while creation setup still owns the agent. */
   private assertDriveEnabled(action: string): void {
     if (driveEnabledAgents.has(this)) return
@@ -221,24 +241,29 @@ export class ReactLoopAgent implements Agent {
 
   send(content: ContentBlock[], options?: SendOptions): void {
     this.assertDriveEnabled('send')
-    if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
-    const source = this.resolveSource(options)
-    this.#inbox.enqueue({ content, source })
-    agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: false })
+    this.assertNotDisposed()
+    const accepted = this.acceptInboxMessage(content, options)
+    // Materialization invokes caller getters, which may reenter handle disposal.
+    this.assertNotDisposed()
+    this.#inbox.enqueue(accepted)
+    const info = deepFreeze({ source: accepted.source, steering: false })
+    agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
   }
 
   steer(content: ContentBlock[], options?: SendOptions): void {
     this.assertDriveEnabled('steer')
-    if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
+    this.assertNotDisposed()
     if (this._status !== 'running') { this.send(content, options); return }
-    const source = this.resolveSource(options)
-    this.#inbox.steer({ content, source })
-    agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: true })
+    const accepted = this.acceptInboxMessage(content, options)
+    this.assertNotDisposed()
+    this.#inbox.steer(accepted)
+    const info = deepFreeze({ source: accepted.source, steering: true })
+    agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
   }
 
   inject(content: ContentBlock[], options?: SendOptions): void {
     this.assertDriveEnabled('inject')
-    if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
+    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 —

+ 6 - 1
packages/core/agent-loop/src/loop.ts

@@ -291,11 +291,15 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
       // the previous turn/end), where the persistence backend drops it as a
       // crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the
       // driver survives and moves on.
+      /* v8 ignore start -- defensive internal-corruption backstop: public
+       * send/steer input is accepted as lossless JSON before enqueue, and
+       * runTurn contains every failure after turn/start. */
       const err = toError(error)
       ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
       try {
         events.emit('agent/error', turn, 0, err)
       } catch { /* contained: a throwing agent/error listener must not kill the driver */ }
+      /* v8 ignore stop */
     }
 
     // Reset the cancel marker UNCONDITIONALLY here, after the turn returns and
@@ -730,9 +734,10 @@ async function runTurn(
     // `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run
     // already in a step branch, so running it again is a safe no-op. Absent
     // turn/start means the append threw BEFORE its push (a non-serializable
-    // trigger — impossible for our fixed trigger); nothing was opened, so rethrow
+    // trigger outside the public lossless-JSON boundary); nothing was opened, so rethrow
     // to the runLoop backstop.
     const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
+    /* v8 ignore next -- defensive internal-corruption path; public inbox input is lossless JSON */
     if (!turnStartLogged) throw error
     closeStep()
     // Choose the close reason. Disposal wins only if no error was already

+ 16 - 23
packages/core/agent-loop/tests/coverage-edges.spec.ts

@@ -35,31 +35,24 @@ function send(agent: ReactLoopAgent, text: string) {
   agent.send([{ type: 'text', text }])
 }
 
-describe('turn boundary listener throws (handled in-turn, loop survives)', () => {
-  it('a pre-push turn/start failure (non-serializable source) is rethrown to the runLoop backstop', async () => {
-    // A non-serializable message source makes the turn/start append throw BEFORE
-    // the event is pushed (Session.append validates before push), so turn/start
-    // never enters the log. runTurn sees no logged turn/start and rethrows; the
-    // runLoop backstop reports via agent/error (step 0) + the logger and the
-    // driver survives. This is the ONLY path that reaches the backstop.
-    const adapter = new MockAdapter([textResponse('turn 2')])
+describe('inbox acceptance', () => {
+  it('rejects non-serializable content or source synchronously before notification or enqueue', async () => {
+    const adapter = new MockAdapter([textResponse('turn 1')])
     const ctx = await harness(adapter)
     const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
-
-    const errors: { turn: number; step: number; message: string }[] = []
-    ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
-
-    // A non-serializable source (BigInt) on the queued message.
-    agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
-    await waitForIdle(ctx, agent)
-
-    expect(errors).toHaveLength(1)
-    expect(errors[0]!.step).toBe(0)
-    expect(errors[0]!.message).toMatch(/non-JSON-serializable/)
-    // No turn boundary was written (the turn/start append threw before push).
-    expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
-
-    // loop survives: a well-formed second turn runs normally.
+    let queued = 0
+    ctx.on('agent/queued', () => { queued += 1 })
+
+    expect(() => {
+      agent.send([{ type: 'text', text: 'first', bad: 1n } as never])
+    }).toThrow(/losslessly JSON-serializable/)
+    expect(() => {
+      agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
+    }).toThrow(/losslessly JSON-serializable/)
+    expect(queued).toBe(0)
+    expect(agent.session.events).toHaveLength(0)
+
+    // The rejected value never woke or poisoned the loop; a valid message runs.
     send(agent, 'second')
     await waitForIdle(ctx, agent)
     expect(adapter.requests).toHaveLength(1)

+ 122 - 1
packages/core/agent-loop/tests/review-fixes.spec.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
-import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
+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'
@@ -437,6 +437,127 @@ describe('MEDIUM: misc registry and config fixes', () => {
     const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : [])
     expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
   })
+
+  it('send() owns content and source before notification and delivery', async () => {
+    const adapter = new MockAdapter([textResponse('done')])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create(AgentId('owned-send'), { model: 'mock' })
+    const content = [{ type: 'text' as const, text: 'accepted-send' }]
+    const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
+    let notifiedContent: ContentBlock[] | undefined
+    let notifiedSource: MessageSource | undefined
+    let notifiedInfoFrozen = false
+    ctx.on('agent/queued', (subject, acceptedContent, info) => {
+      if (subject !== agent || info.steering) return
+      // Retain the exact notification references: cloning here would test the
+      // listener's copy rather than the event/inbox ownership boundary.
+      notifiedContent = acceptedContent
+      notifiedSource = info.source
+      notifiedInfoFrozen = Object.isFrozen(info)
+    })
+
+    agent.send(content, { source })
+    content[0]!.text = 'caller-mutated-send'
+    source.plugin = 'caller-mutated-source'
+    await waitForIdle(ctx, agent)
+
+    expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-send' }])
+    expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' })
+    expect(Object.isFrozen(notifiedContent)).toBe(true)
+    expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
+    expect(Object.isFrozen(notifiedSource)).toBe(true)
+    expect(notifiedInfoFrozen).toBe(true)
+    const recorded = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
+    expect(recorded).toContainEqual({
+      content: [{ type: 'text', text: 'accepted-send' }],
+      source: { kind: 'plugin', plugin: 'accepted-source' },
+    })
+    const request = JSON.stringify(adapter.requests[0]!.messages)
+    expect(request).toContain('accepted-send')
+    expect(request).not.toContain('caller-mutated-send')
+  })
+
+  it('send() rechecks disposal after materializing caller getters', async () => {
+    const adapter = new MockAdapter([textResponse('unused')])
+    const ctx = await harness(adapter)
+    const handle = await ctx.agents.create({
+      agentId: AgentId('reentrant-send-dispose'),
+      sessionId: SessionId('reentrant-send-dispose-session'),
+      agentOptions: { model: 'mock' },
+    })
+    const { agent } = handle
+    let queued = 0
+    ctx.on('agent/queued', subject => void (queued += Number(subject === agent)))
+    const content = [{
+      type: 'text' as const,
+      get text() {
+        void handle.dispose()
+        return 'accepted-after-dispose'
+      },
+    }]
+
+    expect(() => { agent.send(content) }).toThrow(/agent "reentrant-send-dispose" is disposed/)
+    await handle.dispose()
+
+    expect(queued).toBe(0)
+    expect(agent.session.events).toHaveLength(0)
+    expect(adapter.requests).toHaveLength(0)
+  })
+
+  it('running steer() owns content and source before notification and delivery', async () => {
+    const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create(AgentId('owned-steer'), { model: 'mock' })
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    ctx.tools.register(defineTool({
+      name: 'gate',
+      description: '',
+      parameters: {},
+      async execute() {
+        entered.resolve(undefined)
+        await release.promise
+        return [{ type: 'text', text: 'tool done' }]
+      },
+    }))
+    let notifiedContent: ContentBlock[] | undefined
+    let notifiedSource: MessageSource | undefined
+    let notifiedInfoFrozen = false
+    ctx.on('agent/queued', (subject, acceptedContent, info) => {
+      if (subject !== agent || !info.steering) return
+      notifiedContent = acceptedContent
+      notifiedSource = info.source
+      notifiedInfoFrozen = Object.isFrozen(info)
+    })
+
+    agent.send([{ type: 'text', text: 'start' }])
+    await entered.promise
+    expect(agent.status).toBe('running')
+    const content = [{ type: 'text' as const, text: 'accepted-steer' }]
+    const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
+    agent.steer(content, { source })
+    content[0]!.text = 'caller-mutated-steer'
+    source.plugin = 'caller-mutated-source'
+    const idle = waitForIdle(ctx, agent)
+    release.resolve(undefined)
+    await idle
+
+    expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-steer' }])
+    expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' })
+    expect(Object.isFrozen(notifiedContent)).toBe(true)
+    expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
+    expect(Object.isFrozen(notifiedSource)).toBe(true)
+    expect(notifiedInfoFrozen).toBe(true)
+    const recorded = agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data] : [])
+    expect(recorded).toContainEqual({
+      turn: 1,
+      content: [{ type: 'text', text: 'accepted-steer' }],
+      source: { kind: 'plugin', plugin: 'accepted-source' },
+    })
+    const request = JSON.stringify(adapter.requests[1]!.messages)
+    expect(request).toContain('accepted-steer')
+    expect(request).not.toContain('caller-mutated-steer')
+  })
 })
 
 describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () => {

+ 2 - 2
packages/core/agent/README.md

@@ -39,8 +39,8 @@ Turn and step boundaries and the model token stream are durable `session/event`
 
 The handle every plugin programs against:
 
-- `agent.send(content, options?)` — queue a message; starts a turn when idle
-- `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle
+- `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.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.

+ 18 - 6
packages/core/agent/src/types.ts

@@ -202,12 +202,21 @@ export interface Agent {
    */
   readonly ctx: Context
 
-  /** Queue a user message. Starts a turn when idle; otherwise waits for the next turn. */
+  /**
+   * Queue a user message. Starts a turn when idle; otherwise waits for the next
+   * turn. Content and the resolved source are accepted as one detached,
+   * deeply-frozen lossless-JSON record before notification or enqueue, so
+   * caller or `agent/queued` listener in-place mutation cannot change later
+   * log/model input. Throws synchronously when either value is not losslessly
+   * JSON-serializable; `agent/prompt-submit` may still return an explicit
+   * replacement.
+   */
   send(content: ContentBlock[], options?: SendOptions): void
 
   /**
    * Steer a running turn: content is injected between steps of the current
-   * turn. When idle, behaves like {@link send}.
+   * turn. Uses the same owned-value and synchronous-validation boundary as
+   * {@link send}; when idle, behaves exactly like that method.
    */
   steer(content: ContentBlock[], options?: SendOptions): void
 
@@ -335,11 +344,14 @@ declare module 'cordis' {
      */
     'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
     /**
-     * A message entered the agent's inbox (queued or steering). `source` is
-     * the resolved source (defaults applied), not the caller's raw options.
+     * A message entered the agent's inbox (queued or steering). Content and the
+     * resolved source are the detached, deeply-frozen values retained by the
+     * inbox; the `info` wrapper is frozen too, so one listener cannot rewrite
+     * what another listener observes. `source` has defaults applied and is not
+     * the caller's raw options.
      * @param agent - the agent whose inbox received the message.
-     * @param content - the enqueued content blocks, verbatim.
-     * @param info - the resolved source plus whether it entered as steering.
+     * @param content - the accepted content blocks retained by the inbox.
+     * @param info - the accepted source plus whether it entered as steering.
      * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
      * through `agent.ctx` fires only for that agent's dispatches; a listener on a
      * plain plugin context fires for every agent. The dispatch `this` is the