Pārlūkot izejas kodu

fix(agent-loop): own queued message input

Tianyi Cui 2 mēneši atpakaļ
vecāks
revīzija
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)
 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
 ### `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)
 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
 ### `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)
 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
 ### `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)
 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
 ### `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)
 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
 ### `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
 ```ts cordis-catalog
 'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
 '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)
 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
 ### `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)
 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
 ### `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)
 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
 ### `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)
 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
 ### `agent/status` — emit
 
 
@@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
 
 
 Types: [Agent](../core-data-structures/core.md)
 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
 ### `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)
 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
 ### `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)
 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
 ### `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)
 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/*`
 ## `approval/*`
 
 

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

@@ -267,12 +267,21 @@ interface Agent {
    */
    */
   readonly ctx: Context
   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
   send(content: ContentBlock[], options?: SendOptions): void
 
 
   /**
   /**
    * Steer a running turn: content is injected between steps of the current
    * 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
   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 |
 | 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) |
 | `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/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) |
 | `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 |
 | 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 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 |
 | Approval request | Agent and abort signal | Tool name, call ID, and reason |
 | Tool execution | Agent, signal, registry-minted parent token | Call identity and arguments |
 | Tool execution | Agent, signal, registry-minted parent token | Call identity and arguments |
 | Session append/load | Session identity | Header and event envelopes |
 | 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.
 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:
 A stateful getter shows why validation and ownership must use the same capture:
 
 
 ```js
 ```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.
 - `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`)
 ### 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 type { Agent } from '@deepseek-ai/dsh-agent'
 import { deepFreeze } from '@deepseek-ai/dsh-llm'
 import { deepFreeze } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, MessageSource } 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'
 import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
 
 
 /** Agents whose rollback-covered publication enabled driving. */
 /** Agents whose rollback-covered publication enabled driving. */
@@ -213,6 +213,26 @@ export class ReactLoopAgent implements Agent {
     return options?.source ?? { kind: 'user' }
     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. */
   /** Reject every driving verb while creation setup still owns the agent. */
   private assertDriveEnabled(action: string): void {
   private assertDriveEnabled(action: string): void {
     if (driveEnabledAgents.has(this)) return
     if (driveEnabledAgents.has(this)) return
@@ -221,24 +241,29 @@ export class ReactLoopAgent implements Agent {
 
 
   send(content: ContentBlock[], options?: SendOptions): void {
   send(content: ContentBlock[], options?: SendOptions): void {
     this.assertDriveEnabled('send')
     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 {
   steer(content: ContentBlock[], options?: SendOptions): void {
     this.assertDriveEnabled('steer')
     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 }
     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 {
   inject(content: ContentBlock[], options?: SendOptions): void {
     this.assertDriveEnabled('inject')
     this.assertDriveEnabled('inject')
-    if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
+    this.assertNotDisposed()
     const source = this.resolveSource(options)
     const source = this.resolveSource(options)
     if (isTurnOpen(this.session)) {
     if (isTurnOpen(this.session)) {
       // A turn is open in the LOG (decided from the log, not agent status —
       // 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
       // 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
       // crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the
       // driver survives and moves on.
       // 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)
       const err = toError(error)
       ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
       ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
       try {
       try {
         events.emit('agent/error', turn, 0, err)
         events.emit('agent/error', turn, 0, err)
       } catch { /* contained: a throwing agent/error listener must not kill the driver */ }
       } 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
     // 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
     // `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
     // 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
     // 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.
     // to the runLoop backstop.
     const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
     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
     if (!turnStartLogged) throw error
     closeStep()
     closeStep()
     // Choose the close reason. Disposal wins only if no error was already
     // 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 }])
   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 ctx = await harness(adapter)
     const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     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')
     send(agent, 'second')
     await waitForIdle(ctx, agent)
     await waitForIdle(ctx, agent)
     expect(adapter.requests).toHaveLength(1)
     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 { describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
 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 SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
 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] : [])
     const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : [])
     expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
     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', () => {
 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:
 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.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.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.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
   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
   send(content: ContentBlock[], options?: SendOptions): void
 
 
   /**
   /**
    * Steer a running turn: content is injected between steps of the current
    * 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
   steer(content: ContentBlock[], options?: SendOptions): void
 
 
@@ -335,11 +344,14 @@ declare module 'cordis' {
      */
      */
     'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
     '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 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
      * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
      * through `agent.ctx` fires only for that agent's dispatches; a listener on a
      * 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
      * plain plugin context fires for every agent. The dispatch `this` is the