diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index ae549af20f..db77315800 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:229`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:235`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,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:354`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial @@ -63,7 +63,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) #### `agent/prompt-submit` — waterfall @@ -75,7 +75,7 @@ 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:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -87,7 +87,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:248`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -99,7 +99,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:320`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:324`](../../packages/core/agent/src/types.ts) #### `agent/session-start` — emit @@ -111,7 +111,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:261`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -123,7 +123,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -135,7 +135,7 @@ Steering content was injected into a running turn. 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:348`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:352`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -147,7 +147,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:326`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:330`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -159,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:347`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -171,7 +171,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:336`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/types.ts) ### `llm/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 68f5357e3a..1846bf0bdc 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -214,7 +214,7 @@ type SessionEvent = { }[T] ``` -The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## The agent handle diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 3ec8215385..3d679332e2 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -16,6 +16,17 @@ interface SessionEventMap { 'step/end': { turn: number; step: number } /** A user-visible prompt (queued message drained at turn start). */ 'user/message': { content: ContentBlock[]; source: MessageSource } + /** + * A queued prompt an `agent/prompt-submit` listener VETOED — the durable + * record of a blocked prompt and why. Appended in place of the `user/message` + * the prompt would have become, so the block survives replay even in a MIXED + * batch where another queued prompt is allowed (there the turn does not end + * `rejected`, so the boundary reason alone would not preserve it). `content` + * is the original prompt the listener rejected; `reason` is the veto text + * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a + * blocked prompt produces no LLM message and never reaches `deriveMessages()`. + */ + 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** * In-session context injection (file-change notices, subdir AGENTS.md, * skill content, cron notifications, …). Rendered into the derived history diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index aafa24cb1d..cda9f00e9e 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -22,7 +22,7 @@ Three deliberate choices: 3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`. -`dsh-bash-local` now ALWAYS spawns stdin as a `'pipe'` and closes it immediately — with the supplied bytes when a caller set `stdin`, empty otherwise. A closed empty pipe gives a reading child EOF exactly as the previous `'ignore'` (`/dev/null`) did, so the no-stdin path is behavior-equivalent; keeping the `stdio` tuple a literal `['pipe','pipe','pipe']` also preserves the typed `spawn` overload that guarantees non-null `stdout`/`stderr`. A child that exits without reading makes the stdin write fail EPIPE; that error is swallowed (the command's outcome rides on its exit code/output, not the write) so it never crashes the host or rejects `done`. +`dsh-bash-local` spawns stdin as a `'pipe'` (writing the supplied bytes, then closing) ONLY when a caller set `stdin`; with none supplied it uses `'ignore'` — fd 0 → `/dev/null` — the exact pre-seam default. This distinction is observable and deliberate: a closed empty pipe and `/dev/null` are NOT the same file type (node's spawn pipe is an `AF_UNIX` socket, so `test -c /dev/stdin` holds for `/dev/null` but not for an empty pipe), so the no-stdin path — every model-driven call — must keep `/dev/null` rather than regress to an always-open pipe. Each branch's `stdio` tuple is a literal, which preserves the typed `spawn` overload that guarantees non-null `stdout`/`stderr`. When stdin IS written, a child that exits without reading makes the write fail EPIPE; that error is swallowed (the command's outcome rides on its exit code/output, not the write) so it never crashes the host or rejects `done`. ## Scope: configurable scrub pattern is NOT included diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index 7f2d23ab3e..138fe975a1 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -16,7 +16,7 @@ Add/​reshape the interception seams so every one returns a small, seam-specifi **New `agent/*` events** (`dsh-agent`): - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. -- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block`. +- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). **Reshaped** `agent/turn-continuation` from `(…, defaultDecision: boolean) → boolean` to `(…, defaultDecision: ContinuationDecision) → ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the existing `/goal` step-end-steer pattern. @@ -26,7 +26,7 @@ Add/​reshape the interception seams so every one returns a small, seam-specifi ### Three load-bearing loop decisions -1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable `turn/end` is appended and the ACP bridge settles normally off it (mapping `rejected`→`cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. An `allow`'s `additionalContext` is `inject()`ed into this now-open turn. +1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn; every veto is recorded as `prompt/blocked`.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable `turn/end` is appended and the ACP bridge settles normally off it (mapping `rejected`→`cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. Independently, each individual veto appends a `prompt/blocked` session event (the original `content`, `source`, and `reason`) in place of the `user/message` the prompt would have become — necessary because a MIXED batch (one prompt blocked, another allowed) does NOT end `rejected`, so the boundary reason alone would silently lose the blocked prompt on replay. An `allow`'s `additionalContext` is `inject()`ed into this now-open turn. 2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended. diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index b62f1e22c4..109debc24c 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -21,7 +21,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. - **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after a 3s grace (OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. - **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file. -- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin` is written to the child and closed; with none supplied, stdin is an immediately-closed empty pipe (EOF, as before). Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). - **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload. ## Sandboxing diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index de3d880c7c..023ea0e3d1 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -15,7 +15,8 @@ * @module dsh-bash-local/run */ -import { spawn } from 'node:child_process' +import { type ChildProcessByStdio, spawn } from 'node:child_process' +import type { Readable, Writable } from 'node:stream' import { randomBytes } from 'node:crypto' import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -298,17 +299,20 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) } - // stdin is ALWAYS a pipe (kept literal so the typed spawn overload guarantees - // non-null stdout/stderr) and is closed immediately: with bytes when a caller - // supplied stdin, empty otherwise. A closed empty pipe gives a reading child - // EOF exactly as `/dev/null` would, so the no-stdin path (every model-driven - // call) is unchanged. - const child = spawn('bash', ['-c', spec.command], { - cwd: spec.cwd, - env: childEnv(spec.env), - stdio: ['pipe', 'pipe', 'pipe'], - detached: true, - }) + // stdin is a pipe ONLY when the caller supplied bytes; with none it is `ignore` + // (fd 0 → /dev/null) — the exact pre-seam default. This matters: a spawn pipe + // and /dev/null are NOT observationally identical (node's pipe is an AF_UNIX + // socket, so a command that probes stdin's type — `test -c /dev/stdin`, `stat + // /proc/self/fd/0` — sees a char device vs a socket), so the no-stdin path + // (every model-driven call) must keep /dev/null rather than regress to a socket. + // Two LITERAL `stdio` tuples (not one variable tuple): only a literal lets the + // typed `spawn` overload infer non-null stdout/stderr, which the + // `ChildProcessByStdio` annotation captures (stdin `Writable | null`; stdout/ + // stderr the non-null `Readable` the collectors attach to without a cast). + const env = childEnv(spec.env) + const child: ChildProcessByStdio = spec.stdin !== undefined + ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) + : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir) const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir) @@ -343,10 +347,12 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB } spec.signal?.addEventListener('abort', onAbort, { once: true }) - // Write stdin and close it. This handler must exist: an unhandled 'error' on - // the stream would throw and crash the host. We swallow the error rather than - // reject `done`, and that is correct for ANY stdin-write error, not just the - // common one — the stdin write is BEST-EFFORT, while the command's authoritative + // Write stdin and close it, but ONLY when the caller supplied bytes — with no + // stdin, fd 0 is `ignore` (/dev/null) and `child.stdin` is null. The error + // handler must exist whenever we write: an unhandled 'error' on the stream + // would throw and crash the host. We swallow the error rather than reject + // `done`, and that is correct for ANY stdin-write error, not just the common + // one — the stdin write is BEST-EFFORT, while the command's authoritative // outcome is its exit code + captured output, which the `close` handler reports // regardless of whether the write landed. The expected case is EPIPE (the child // exited without reading, so closing our end of a still-full pipe fails); a rare @@ -354,8 +360,10 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB // surfaces that itself through its own exit/output (e.g. a hook that gets // truncated JSON errors out) — rejecting here would instead discard that real // output and turn it into an opaque infrastructure error, which is worse. - child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) - child.stdin.end(spec.stdin ?? '') + if (child.stdin !== null) { + child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) + child.stdin.end(spec.stdin) + } const done = new Promise((resolve, reject) => { child.on('error', (error) => { diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index 395f442dad..3a1ff7c2c5 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -166,13 +166,26 @@ describe('stdin and extra env (set by in-process plugins)', () => { }) it('a command that reads stdin sees EOF when none is supplied', async () => { - // No stdin → the always-piped-but-empty stdin closes immediately, so `cat` - // reads EOF and exits 0 with no output (it does NOT block). + // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no + // output (it does NOT block). const result = await runBash(spec('cat')).done expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('') }) + it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { + // The no-stdin path must stay observationally identical to the pre-seam + // `ignore` default: a command that probes stdin's file type sees a char + // device (/dev/null). Regressing to an always-open pipe would make fd 0 a + // socket (node's spawn pipe is an AF_UNIX socket, not a FIFO), flipping + // `test -c /dev/stdin` for every model-driven call. When bytes ARE supplied, + // fd 0 is that pipe (a socket), as it must be to carry them. + const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done + expect(none.stdout.text).toBe('char\n') + const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done + expect(piped.stdout.text).toBe('socket\n') + }) + it('merges extra env entries onto the scrubbed environment', async () => { const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', { env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' }, diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 7bb41be102..1cf6028f98 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -51,7 +51,7 @@ forever: TURN (error-contained): 'turn/start' each queued: waterfall agent/prompt-submit → allow (→ session('user/message'), - inject additionalContext) | block (drop) + inject additionalContext) | block (→ session('prompt/blocked'), drop) if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn STEP loop: drain steering diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 2923a3dd22..d08b5ccdba 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -385,6 +385,14 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, ) if (decision.kind === 'block') { lastBlockReason = decision.reason + // Record the veto durably: `PromptDecision.reason` is the durable record + // of why a prompt was blocked, but a fully-blocked batch's `rejected` + // turn/end only preserves the LAST reason, and a MIXED batch (this prompt + // blocked, another allowed) does not end `rejected` at all — so without + // this append a blocked prompt would vanish from the log whenever any + // sibling prompt is allowed. `prompt/blocked` sits in the open turn in + // place of the `user/message` this prompt would have become. + session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason }) continue } anyAllowed = true diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index b2a5c932d6..e76ac30fa9 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -176,12 +176,57 @@ describe('agent/prompt-submit', () => { expect(log.some(e => e.type === 'turn/end')).toBe(true) expect(log.some(e => e.type === 'user/message')).toBe(false) expect(log.some(e => e.type === 'step/start')).toBe(false) + // the veto is recorded durably as a prompt/blocked in the open turn + const blocked = log.find(e => e.type === 'prompt/blocked') + expect(blocked?.type === 'prompt/blocked' && blocked.data).toMatchObject({ + content: [{ type: 'text', text: 'do something' }], + reason: 'blocked by policy', + }) // ended rejected with the block reason expect(reasons).toEqual([{ kind: 'rejected', reason: 'blocked by policy' }]) const turnEnd = log.findLast(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' }) }) + it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => { + // Two prompts queued into ONE turn: block "secret", allow "safe". The turn is + // NOT rejected (a prompt was allowed), so without a durable prompt/blocked the + // vetoed prompt and its reason would vanish from the log entirely. + const adapter = new MockAdapter([textResponse('ran once')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise => { + const text = content.map(b => (b.type === 'text' ? b.text : '')).join('') + return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next() + }) + + const reasons: TurnEndReason[] = [] + ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) + + // both sends land before the loop drains → one batched turn + send(agent, 'secret') + send(agent, 'safe') + await waitForIdle(ctx, agent) + + const log = events(agent) + // the allowed prompt became a user/message and drove exactly one model call + const userMsgs = log.filter(e => e.type === 'user/message') + expect(userMsgs).toHaveLength(1) + expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }]) + expect(adapter.requests.length).toBeGreaterThanOrEqual(1) + // the blocked prompt is durably recorded, with its content + reason + const blocked = log.filter(e => e.type === 'prompt/blocked') + expect(blocked).toHaveLength(1) + expect(blocked[0]?.type === 'prompt/blocked' && blocked[0].data).toMatchObject({ + content: [{ type: 'text', text: 'secret' }], + reason: 'policy: no secrets', + }) + // the turn did NOT reject — a sibling was allowed — so the boundary reason + // alone would not have preserved the block + expect(reasons.some(r => r.kind === 'rejected')).toBe(false) + }) + it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => { const adapter = new MockAdapter([textResponse('after')]) const ctx = await harness(adapter) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index b92bc08d39..a8e2113b25 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -96,10 +96,14 @@ export interface HookContext { * - `allow` proceeds with the prompt; optional `content` REPLACES the prompt * bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a * separate `context/message` the next request also sees. - * - `block` drops the prompt entirely; `reason` is the durable record of why. - * A batch whose every prompt is blocked still opens a zero-step turn that ends - * with {@link TurnEndReason} `rejected` (so the boundary stays balanced and a - * UI can render "blocked by hook"). + * - `block` drops the prompt (it never becomes a `user/message`); `reason` is + * the durable record of why. The loop appends a `prompt/blocked` session event + * (carrying the original content, source, and `reason`) in place of the + * dropped `user/message`, so the veto survives replay even in a MIXED batch + * where a sibling prompt is allowed. A batch whose EVERY prompt is blocked + * additionally opens a zero-step turn that ends with {@link TurnEndReason} + * `rejected` (so the boundary stays balanced and a UI can render "blocked by + * hook"). */ export type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 45512c9d9e..96d267c79b 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -49,7 +49,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. ### Session event vocabulary (`types.ts`) -The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `todo/write`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`. +The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `todo/write`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`. Merge-extensible via `SessionEventMap` — the compaction seam adds `compact/start`, `compact/summary`, and `compact/end`. diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index c26750c606..13965d823d 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -204,6 +204,17 @@ export interface SessionEventMap { 'step/end': { turn: number; step: number } /** A user-visible prompt (queued message drained at turn start). */ 'user/message': { content: ContentBlock[]; source: MessageSource } + /** + * A queued prompt an `agent/prompt-submit` listener VETOED — the durable + * record of a blocked prompt and why. Appended in place of the `user/message` + * the prompt would have become, so the block survives replay even in a MIXED + * batch where another queued prompt is allowed (there the turn does not end + * `rejected`, so the boundary reason alone would not preserve it). `content` + * is the original prompt the listener rejected; `reason` is the veto text + * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a + * blocked prompt produces no LLM message and never reaches `deriveMessages()`. + */ + 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** * In-session context injection (file-change notices, subdir AGENTS.md, * skill content, cron notifications, …). Rendered into the derived history diff --git a/packages/support/ui-stdio/README.md b/packages/support/ui-stdio/README.md index 25f53833b7..7e88bbc459 100644 --- a/packages/support/ui-stdio/README.md +++ b/packages/support/ui-stdio/README.md @@ -25,7 +25,7 @@ This package consolidates what were two near-identical copies under `examples/ec Rendering is **global** — every agent's events are written to stdout, not just `config.agent`'s. `config.agent` scopes only *input* (which agent stdin drives) and the EOF-exit gate; the single-agent demos this serves have just one agent, so the distinction is moot for them. (A multi-agent UI that needs per-agent panes would filter these handlers by the agent argument — deliberately out of scope here.) - `agent/stream-chunk` — `text-delta` is written verbatim; `reasoning-delta` is wrapped in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer. Reasoning rendering is inert when no `reasoning-delta` chunks arrive (e.g. a mock model), so it is always on. -- `session/event` — the durable transcript feed drives all boundary and content rendering: `turn/start` prints a `[ turn N]` header (the short agent label comes from an `agent/created`→id map, since the turn event carries only the turn number), `turn/end` prints the trailing `> ` prompt, `tool/call` renders `[tool call] name(args)`, `tool/result` renders the joined text blocks as `[tool result] …`, and `todo/write` renders a glyphed checklist. +- `session/event` — the durable transcript feed drives all boundary and content rendering: `turn/start` prints a `[ turn N]` header (the short agent label comes from a session-id→agent-id map seeded from `ctx.agents.list()` at install and kept live via `agent/created`/`agent/disposed`, since the turn event carries only the turn number), `turn/end` prints the trailing `> ` prompt, `tool/call` renders `[tool call] name(args)`, `tool/result` renders the joined text blocks as `[tool result] …`, and `todo/write` renders a glyphed checklist. ## The I/O seam diff --git a/packages/support/ui-stdio/src/index.ts b/packages/support/ui-stdio/src/index.ts index 5f3b2bdc1c..9be922426f 100644 --- a/packages/support/ui-stdio/src/index.ts +++ b/packages/support/ui-stdio/src/index.ts @@ -80,8 +80,14 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // number, so to print the short agent id (`[main turn 1]`) we map the // session's id to its agent's id. The session id is not reliably the agent id // (a session can be created with an explicit/client-supplied id), so build the - // map from `agent/created` rather than parsing the id string. + // map from `agent/created` rather than parsing the id string. Seed from the + // registry's current agents first: an agent registered before this plugin + // installed (e.g. the pre-created `main` agent, or any agent surviving an HMR + // reload of just this fiber) already fired its `agent/created`, so the live + // listener alone would miss it and its turns would fall back to the raw + // session id. const labelBySession = new Map() + for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id) ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) }) ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) }) diff --git a/packages/support/ui-stdio/tests/readline.spec.ts b/packages/support/ui-stdio/tests/readline.spec.ts index c8b147ddab..5e092fb913 100644 --- a/packages/support/ui-stdio/tests/readline.spec.ts +++ b/packages/support/ui-stdio/tests/readline.spec.ts @@ -16,6 +16,9 @@ function fakeContext(): Context { return { on: vi.fn(() => vi.fn()), effect: vi.fn((callback: () => () => void) => callback()), + // The UI seeds its label map from the registry at install; this suite only + // exercises readline terminal-mode selection, so an empty roster suffices. + agents: { list: vi.fn(() => []) }, } as unknown as Context } diff --git a/packages/support/ui-stdio/tests/ui-stdio.spec.ts b/packages/support/ui-stdio/tests/ui-stdio.spec.ts index 0c58211d82..bf75c528c3 100644 --- a/packages/support/ui-stdio/tests/ui-stdio.spec.ts +++ b/packages/support/ui-stdio/tests/ui-stdio.spec.ts @@ -149,6 +149,26 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('[orphan-session turn 1] ') }) + it('seeds labels for agents already registered before the UI installs', async () => { + // The pre-created `main` agent (and any agent surviving an HMR reload of just + // this fiber) fired its `agent/created` before the UI's listener existed, so + // the live listener alone would miss it. Seeding from `ctx.agents.list()` at + // install time is what keeps its turn header showing `[main turn N]` instead + // of the raw session id. + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const agent = makeAgent('main') + ctx.agents.register(agent) // registered BEFORE the UI plugin below + const { runtime, out } = makeRuntime() + await ctx.plugin(Object.assign((inner: Context) => { + createStdioChat(inner, CONFIG, runtime) + }, { inject: ['agents'] })) + ctx.emit('session/event', makeSession('main'), { + type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } }, + } as SessionEvent) + expect(out.text()).toContain('[main turn 5] ') + }) + it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => { const { ctx, out } = await setup() const agent = makeAgent('main')