fix(agent-loop): own queued message input

This commit is contained in:
Tianyi Cui
2026-07-12 16:54:37 +08:00
parent 7e3d46a3ce
commit a34801df4b
11 changed files with 240 additions and 72 deletions

View File

@@ -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/*`

View File

@@ -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

View File

@@ -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/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:322`](../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:596`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `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: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/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: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/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:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `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:475`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `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:527`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | | `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: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/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: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/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:542`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `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: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-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:579`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `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) |

View File

@@ -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

View File

@@ -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`)

View File

@@ -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 { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session'
import { Inbox } from './inbox.ts' 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`) this.assertNotDisposed()
const source = this.resolveSource(options) const accepted = this.acceptInboxMessage(content, options)
this.#inbox.enqueue({ content, source }) // Materialization invokes caller getters, which may reenter handle disposal.
agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: false }) 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) const accepted = this.acceptInboxMessage(content, options)
this.#inbox.steer({ content, source }) this.assertNotDisposed()
agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: true }) 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 —

View File

@@ -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

View File

@@ -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)', () => { describe('inbox acceptance', () => {
it('a pre-push turn/start failure (non-serializable source) is rethrown to the runLoop backstop', async () => { it('rejects non-serializable content or source synchronously before notification or enqueue', async () => {
// A non-serializable message source makes the turn/start append throw BEFORE const adapter = new MockAdapter([textResponse('turn 1')])
// 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')])
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' })
let queued = 0
ctx.on('agent/queued', () => { queued += 1 })
const errors: { turn: number; step: number; message: string }[] = [] expect(() => {
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message })) 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)
// A non-serializable source (BigInt) on the queued message. // The rejected value never woke or poisoned the loop; a valid message runs.
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.
send(agent, 'second') send(agent, 'second')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) expect(adapter.requests).toHaveLength(1)

View File

@@ -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', () => {

View File

@@ -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.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); behaves like `send` when idle - `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.

View File

@@ -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 * A message entered the agent's inbox (queued or steering). Content and the
* the resolved source (defaults applied), not the caller's raw options. * 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 content - the accepted content blocks retained by the inbox.
* @param info - the resolved source plus whether it entered as steering. * @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