diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d59bc617d0..b69ed834b3 100644 --- a/docs/cordis-catalog/events.md +++ b/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, 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/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 7937018b9f..bd533fe5c4 100644 --- a/docs/core-data-structures/core.md +++ b/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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b8bcde4c9a..17e9e74f50 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,19 +7,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts: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) | diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 0fbd3d647e..c94f0cee63 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/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 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index daf7e10d76..8f6b9604c5 100644 --- a/packages/core/agent-loop/README.md +++ b/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`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 6ea60c4958..16f10168b6 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/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 — diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 8c30e42257..756c007c45 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/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 diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 7a044bfb57..ee58fcd66e 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/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' }) + let queued = 0 + ctx.on('agent/queued', () => { queued += 1 }) - const errors: { turn: number; step: number; message: string }[] = [] - ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message })) + 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) - // 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. + // 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) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 3cfa743b65..4c68ac53bd 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/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() + const release = Promise.withResolvers() + 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', () => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index ad34cbd76d..3663808847 100644 --- a/packages/core/agent/README.md +++ b/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. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 0e914888b9..419cc9d1fe 100644 --- a/packages/core/agent/src/types.ts +++ b/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, 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