From 44fd93fd06fff32e83351c77673b25c9af5dfb93 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 23 Jul 2026 19:15:45 +0800 Subject: [PATCH 01/37] =?UTF-8?q?feat(agent):=20unify=20send(target=20?= =?UTF-8?q?=C3=97=20wakeup),=20coalesce=20context/message=20into=20user/me?= =?UTF-8?q?ssage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace send/steer/inject with one Agent.send primitive over the (target × wakeup) matrix; followup/steer/inject become fixed-preset alias methods on the now-abstract Agent class. Coalesce context/message into user/message (injected context is a non-user source). Replace agent/queued with agent/inbox/enqueue/dequeue/discard, add cancel keepInbox, and add a FIFO-conservation invariant. --- ...nified-send-and-coalesced-user-messages.md | 39 ++++ docs/agent-lifecycle.md | 2 +- docs/cordis-catalog/events.md | 116 ++++++--- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 202 ++++++++++++---- docs/core-data-structures/goal.md | 2 +- docs/core-data-structures/session.md | 67 +++--- docs/event-producer-consumer.md | 34 +-- docs/persistence-catalog.md | 70 ++---- docs/tool-catalog.md | 4 +- docs/tool-execution-pipeline.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 6 +- ...ve-mode-switching-2026-07-07.session.jsonl | 8 +- .../goal-session/session.expected.jsonl | 4 +- examples/acp-agent/tests/goal.snapshot.ts | 1 + .../code-mode-workspace-context/session.jsonl | 2 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../hook-cc-posttool-context/session.jsonl | 2 +- .../session.jsonl | 2 +- .../hook-codex-posttool-context/session.jsonl | 2 +- .../session.jsonl | 2 +- .../permission-switching/session.jsonl | 2 +- .../snapshots/repeat-tool-guard/session.jsonl | 4 +- .../snapshots/workspace-context/session.jsonl | 2 +- .../headless-agent/tests/code-mode.e2e.ts | 3 +- .../headless-agent/tests/headless.snapshot.ts | 2 +- .../goal-tools/stream-json.expected.jsonl | 2 +- .../bash/tool-bash/tests/integration.spec.ts | 12 +- .../client/connection/src/client/fixture.ts | 2 +- .../src/client/sessions/fold-adapter.ts | 15 +- .../client/runtime/tests/fold-adapter.spec.ts | 2 +- .../compact-basic/tests/compact-basic.spec.ts | 7 +- .../compact/tests/tool-pairing.spec.ts | 12 +- .../session-reference/src/projection.ts | 1 - .../tests/session-reference.spec.ts | 2 +- packages/context/time-context/src/index.ts | 5 +- .../context/time-context/src/invariant.ts | 6 +- .../time-context/tests/invariant.spec.ts | 6 +- .../time-context/tests/time-context.e2e.ts | 2 +- .../time-context/tests/time-context.spec.ts | 22 +- .../context/workspace-context/src/state.ts | 4 +- .../tests/workspace-context.e2e.ts | 6 +- .../tests/workspace-context.spec.ts | 36 ++- .../cordis/tool-cordis/src/api-catalog.ts | 58 +++-- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 84 ++++--- packages/core/agent-loop/src/inbox.ts | 31 ++- packages/core/agent-loop/src/loop.ts | 15 +- packages/core/agent-loop/tests/agent.spec.ts | 14 +- packages/core/agent-loop/tests/cancel.spec.ts | 19 ++ .../tests/contract-regressions.spec.ts | 36 +-- .../agent-loop/tests/coverage-edges.spec.ts | 2 +- packages/core/agent-loop/tests/inbox.spec.ts | 28 ++- .../agent-loop/tests/interception.spec.ts | 52 ++--- .../core/agent-loop/tests/invariant.spec.ts | 2 +- packages/core/agent-loop/tests/loop.spec.ts | 24 +- .../tests/request-reconstruction.spec.ts | 4 +- .../agent-loop/tests/request-recovery.spec.ts | 11 +- .../core/agent-loop/tests/tool-calls.spec.ts | 13 +- packages/core/agent/README.md | 13 +- packages/core/agent/src/invariant.ts | 21 ++ packages/core/agent/src/types.ts | 220 +++++++++++++----- packages/core/agent/tests/agent.spec.ts | 16 +- packages/core/agent/tests/invariant.spec.ts | 38 +++ .../core/scope/src/scoped-events.generated.ts | 4 +- packages/core/scope/tests/invariant.spec.ts | 4 +- packages/core/session/README.md | 4 +- packages/core/session/src/index.ts | 5 +- packages/core/session/src/surface.ts | 3 +- packages/core/session/src/types.ts | 59 ++--- .../core/session/tests/derived-cache.spec.ts | 2 +- packages/core/session/tests/session.spec.ts | 12 +- packages/core/session/tests/surface.spec.ts | 7 +- packages/examples/cli-demo/tests/cli.spec.ts | 6 +- .../command-goal/tests/command-goal.spec.ts | 11 +- packages/goal/goal-session/src/index.ts | 4 +- .../goal-session/tests/goal-session.spec.ts | 44 ++-- .../goal/goal-session/tests/invariant.spec.ts | 4 +- packages/goal/goal/src/fold.ts | 53 +++-- packages/goal/goal/src/index.ts | 4 +- packages/goal/goal/src/runtime.ts | 2 +- packages/goal/goal/src/types.ts | 2 +- packages/goal/goal/tests/goal.e2e.ts | 4 +- packages/goal/goal/tests/goal.spec.ts | 37 +-- packages/goal/goal/tests/invariant.spec.ts | 8 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 13 +- .../tests/repeat-tool-guard.spec.ts | 4 +- .../hooks/hooks-claude/tests/bridge.spec.ts | 10 +- .../hooks-claude/tests/coverage-cases.ts | 26 +-- .../hooks/hooks-codex/tests/coverage-cases.ts | 28 +-- packages/plan/plan-mode/src/index.ts | 2 +- .../plan/plan-mode/tests/integration.spec.ts | 9 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 2 +- packages/pty/pty-local/tests/index.spec.ts | 2 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 1 + .../tool-pty/tests/loader-composition.spec.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- .../session-query/tests/tracing.spec.ts | 6 +- .../tests/subagent-inprocess.spec.ts | 2 +- .../tests/subagent-spawn.spec.ts | 4 +- packages/support/invariants/README.md | 2 +- packages/tasks/tasks/tests/tasks.spec.ts | 1 + packages/ui/acp/src/index.ts | 9 +- packages/ui/acp/tests/bridge.spec.ts | 2 +- packages/ui/acp/tests/turns.spec.ts | 4 +- packages/ui/jsonrpc/tests/server.spec.ts | 2 +- packages/ui/tui/src/index.ts | 56 +++-- packages/ui/tui/tests/harness.ts | 4 + .../tui/tests/session-reference.snapshot.ts | 2 +- packages/ui/tui/tests/tui.snapshot.ts | 4 +- packages/ui/tui/tests/tui.spec.ts | 38 +-- scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 4 +- scripts/gen-tool-catalog.ts | 4 +- scripts/type-equiv.manifest.json | 19 +- 117 files changed, 1249 insertions(+), 728 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md new file mode 100644 index 0000000000..0dfa875d0b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -0,0 +1,39 @@ +# Agent Note: Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message + +Status: implemented + +## Problem + +The agent's public driving surface had grown three near-parallel verbs — `send`, `steer`, `inject` — each with its own options type, its own live event story, and its own durable event. `send` and `steer` both queued a frozen inbox record and emitted `agent/queued`; `inject` bypassed the inbox and wrote a separate `context/message` durable event. The three verbs actually vary along only two independent axes: which queue an item joins (a whole new turn versus the active turn) and whether the item makes the model run. Encoding that 2×2 as three hand-written methods hid the symmetry, made "queue a turn without waking the driver" unreachable, and left `cancel()` with no way to abort a turn while preserving queued work. + +Separately, `context/message` and `user/message` had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried `source`/`meta` and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were `context/message`, admitted rounds were `user/message`). + +## Decision + +**One primitive, three preset aliases.** `Agent` is now an abstract class whose single abstract `send(content, { target, wakeup, source, contexts, meta })` covers the (`target` × `wakeup`) matrix. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) are concrete delegates on the base class, so concrete drivers implement `send` once and inherit the ergonomic presets. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `send` defaults to `{ target: 'next-turn', wakeup: true }`, so every prior bare `agent.send(content)` call keeps its exact behavior. `next-turn`/no-wakeup (queue without waking) is now representable with no alias and no current caller. + +**inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position (deferred behind an executing tool batch), or a one-shot `injection` turn when idle. It bypasses the FIFOs entirely and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. + +**context/message is gone.** Injected context is now a `user/message` whose `source` is a non-`user` kind (plugin or goal). `PromptMessageData` gained the optional `meta` that `context/message` carried. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. This keeps goal-authority's human-authority check exactly as strict as before — an injected message defaults to a plugin source and can never satisfy `source.kind === 'user'`. + +**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata. + +**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO; carries `target`/`wakeup` on `InboxItemInfo`), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items). Injection never touches a FIFO and emits none of these. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. + +**cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped). + +## Alternatives considered + +- **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Injected context defaults to a plugin source instead. +- **A typed discriminant field on `PromptMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact. +- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the added `target`/`wakeup` facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. + +## Consequences + +The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. + +## Related + +- [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on. +- [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md) — the precedent for collapsing a mirrored live event. +- [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md) — the cancel-cause signal `keepInbox` extends. diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index b732708e1f..9a97873a17 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -18,7 +18,7 @@ sequenceDiagram participant Persistence participant SDK as UI or SDK listener User->>Agent: send(content) - Agent-->>SDK: agent/queued + Agent-->>SDK: agent/inbox/enqueue Agent->>Driver: queued work wakes driver Driver-->>SDK: agent/status running Driver->>Session: turn/start diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4ca3fd0601..7c9ae4bea8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:329`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:269`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,72 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:365`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:477`](../../packages/core/agent/src/types.ts) + +### `agent/inbox/dequeue` — emit + +The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps. Fires after the item leaves its FIFO and before it becomes a durable message. + +```ts cordis-catalog +/** + * The driver claimed one item out of the inbox: a queued item at a turn + * boundary, or steering drained between steps. Fires after the item leaves + * its FIFO and before it becomes a durable message. + * @param agent - the agent whose inbox item was claimed. + * @param info - the claimed item's accepted content, source, contexts, steering, and wakeup facts. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/dequeue'(this: Scoped, agent: Agent, info: InboxItemInfo): void +``` + +Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/types.ts) + +### `agent/inbox/discard` — emit + +`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them. Fires once per effective clearing call with every discarded item, after `agent/cancel-requested` and before the abort. + +```ts cordis-catalog +/** + * `cancel()` (without `keepInbox`) dropped pending inbox items without + * delivering them. Fires once per effective clearing call with every + * discarded item, after `agent/cancel-requested` and before the abort. + * @param agent - the agent whose inbox was cleared. + * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/discard'(this: Scoped, agent: Agent, items: InboxItemInfo[]): void +``` + +Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) + +### `agent/inbox/enqueue` — emit + +A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `info` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. + +```ts cordis-catalog +/** + * A detached, frozen item entered the agent's inbox (queued or steering + * FIFO). Source defaults are already applied, so `info` holds the exact + * accepted values. This is the enqueue-time live signal; the durable record + * is the eventual `user/message`/`steering/message`. Injection + * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. + * @param agent - the agent whose inbox received the item. + * @param info - the accepted content, source, contexts, steering, and wakeup facts. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/enqueue'(this: Scoped, agent: Agent, info: InboxItemInfo): void +``` + +Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -119,7 +184,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -142,7 +207,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -169,28 +234,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts) - -### `agent/queued` — emit - -Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log. - -```ts cordis-catalog -/** - * Detached, frozen content entered the agent's inbox. Source defaults have - * already been applied, so these are the exact values retained for the log. - * @param agent - the agent whose inbox received the message. - * @param content - the accepted content blocks retained by the inbox. - * @param info - the accepted source, contexts, and whether it entered as steering. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ -'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void -``` - -Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [HookContext](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:374`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -215,7 +259,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:388`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -241,7 +285,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:330`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:442`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -267,7 +311,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:403`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -289,7 +333,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -309,7 +353,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:287`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -332,7 +376,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) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:415`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -354,7 +398,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:341`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:453`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -376,7 +420,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:352`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:464`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0fcb2fc28e..ef40314ed8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1173,7 +1173,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:605`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:604`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6b5596b782..c2d8b56a80 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -323,7 +323,7 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -351,7 +351,7 @@ type SessionEvent = { }[T] ``` -The fourteen 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`, `request/header`), 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`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), 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 @@ -361,10 +361,35 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types ```ts type-equiv /** - * Message options. An omitted source attests direct human input as `{ kind: 'user' }` - * and may authorize policy consumers, so non-human producers must label their content. + * Which inbox queue a {@link Agent.send} item joins: + * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. + * - `next-step` — the item joins the active turn between steps as steering, + * or, when no turn is active, is promoted per its `wakeup` flag. + */ +type SendTarget = 'next-turn' | 'next-step' +``` + +```ts type-equiv +/** + * Options for the unified {@link Agent.send} primitive over the + * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} + * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and + * {@link Agent.inject} (`next-step`/no-wakeup). + * + * An omitted source attests direct human input as `{ kind: 'user' }` and may + * authorize policy consumers, so non-human producers must label their content. */ interface SendOptions { + /** Queue the item joins; defaults to `next-turn`. */ + target?: SendTarget + /** + * Whether this item makes the model run: wake a parked driver (`next-turn`) + * or force a continuation step (`next-step` while running). Defaults to + * `true`. A `false` `next-turn` item queues without waking; a `false` + * `next-step` item attaches durable context without forcing another step + * (the injection preset). + */ + wakeup?: boolean source?: MessageSource /** * Model-facing contexts captured with this inbox item. A queued prompt exposes @@ -372,16 +397,47 @@ interface SendOptions { * records them directly at its next checkpoint. */ contexts?: HookContext[] + /** Opaque JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue } ``` -`InjectOptions` accepts ordinary message attribution and durable model-hidden JSON metadata. Attached contexts belong only to queued or steering input, so synthetic injection cannot accept them: +The fixed-preset aliases own `target` and `wakeup`, so they accept only the remaining fields: ```ts type-equiv -/** Options specific to durable synthetic context injection. */ -interface InjectOptions extends Omit { - /** Opaque JSON state retained in the session event but hidden from the model. */ - meta?: JsonValue +/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ +type AliasSendOptions = Omit +``` + +The `agent/inbox/*` live events carry the resolved facts of one FIFO item; injection bypasses the FIFOs and never appears on them: + +```ts type-equiv +/** + * The resolved facts of one inbox FIFO item, carried by the `agent/inbox/*` + * live events. Source defaults are already applied, so these are the exact + * values the item was accepted with. `steering` is true for a `next-step` + * item drained between steps; a `next-turn` item is claimed at a turn boundary. + */ +interface InboxItemInfo { + content: ContentBlock[] + source: MessageSource + contexts: HookContext[] + /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ + steering: boolean + /** Whether the item is marked to wake the driver or force a continuation. */ + wakeup: boolean +} +``` + +```ts type-equiv +/** Options for {@link Agent.cancel}. */ +interface CancelOptions { + /** + * Preserve queued and steering inbox items instead of discarding them. The + * active turn is still aborted, but un-started and pending work survives for a + * later turn and no `agent/inbox/discard` fires. + */ + keepInbox?: boolean } ``` @@ -392,59 +448,103 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` +`Agent` is an abstract class: concrete drivers implement the abstract members, while `followup`/`steer`/`inject` are shared concrete delegates to the single abstract `send` over the (`target` × `wakeup`) matrix. + ```ts type-equiv -/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ -interface Agent { +/** + * Public agent handle; its concrete implementation is internal to + * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so + * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer}, + * {@link Agent.inject}) are shared concrete delegates over the single abstract + * {@link Agent.send} primitive; concrete drivers implement `send` once. + */ +abstract class Agent { /** The single identity shared with {@link session}. */ - readonly id: SessionId - readonly options: AgentOptions - readonly session: Session - readonly status: AgentStatus + abstract readonly id: SessionId + /** The provider route and model this agent's requests use. */ + abstract readonly options: AgentOptions + /** The live session this agent drives; its log is the durable source of truth. */ + abstract readonly session: Session + /** The current lifecycle state, mirrored on every `agent/status` transition. */ + abstract readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - readonly ctx: Context + abstract readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole - * ordinary message in its FIFO-ordered turn; the next claimed item waits for - * that turn's checkpoint. + * The unified delivery primitive over the (`target` × `wakeup`) matrix. + * Detaches, validates, and freezes one lossless-JSON item, then routes it: + * + * - `next-turn` (default) queues an item that becomes the sole ordinary + * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a + * parked driver, while `wakeup:false` queues without waking. + * - `next-step` with `wakeup:true` submits steering into the active turn + * (idle falls back to a woken `next-turn`). + * - `next-step` with `wakeup:false` injects durable model-facing context + * without running the model: an open turn joins at the current log position + * (deferred behind an executing tool batch until it settles), and an idle + * inject records a one-shot turn with its own durability checkpoint. + * * Attached contexts share the same snapshot and ownership boundary. Invalid - * input throws synchronously before notification or enqueue. + * input throws synchronously before any notification, enqueue, or append. + * @param content - the model-facing content blocks to deliver. + * @param options - target queue, wakeup decision, source, contexts, and meta. */ - send(content: ContentBlock[], options?: SendOptions): void + abstract send(content: ContentBlock[], options?: SendOptions): void /** - * Submit steering while the agent is `running`. An open turn records it at - * the next steering checkpoint before a request or continuation decision; - * policy may stop before another step. After turn close and its checkpoint, - * any remainder is queued for a later turn; terminal `agent/turn-stop`, - * cancellation, or disposal may discard it. Uses the same synchronous - * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. - */ - steer(content: ContentBlock[], options?: SendOptions): void - - /** - * Append detached model-facing context without running the model. An open-turn - * injection joins at the current log position unless the current tool batch is - * executing; then it waits FIFO until that batch settles and drains before turn - * close even when interrupted. Idle injection uses a one-shot turn and durability - * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`. - */ - inject(content: ContentBlock[], options?: InjectOptions): void - - /** - * Clear all queued and steering work, including items waiting to start, and - * abort the active turn. An effective call first emits - * `agent/cancel-requested` with the resolved typed cause. The first cause wins - * for the active turn, and `whenIdle()` resolves after cancellation reaches - * quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op - * and does not arm later work. The active turn snapshots and freezes the cause. + * Clear queued and steering work — unless `keepInbox` — and abort the active + * turn. An effective call first emits `agent/cancel-requested` with the + * resolved typed cause. The first cause wins for the active turn, and + * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause + * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm + * later work. The active turn snapshots and freezes the cause. * @param cause - the stable caller intent carried by the current turn signal. + * @param options - cancellation options; `keepInbox` preserves pending work. */ - cancel(cause?: AgentCancelCause): void + abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ - whenIdle(): Promise + abstract whenIdle(): Promise + /** + * Queue an ordinary follow-up turn and wake the driver — the + * `next-turn`/wakeup preset of {@link send}. The item becomes the sole + * ordinary message of its own turn. + * @param content - the prompt content blocks. + * @param options - source and attached contexts. + */ + followup(content: ContentBlock[], options?: AliasSendOptions): void { + this.send(content, { ...options, target: 'next-turn', wakeup: true }) + } + + /** + * Submit steering into the running turn — the `next-step`/wakeup preset of + * {@link send}. An open turn records it at the next steering checkpoint before + * a request or continuation decision; policy may stop before another step. + * After turn close and its checkpoint, any remainder is queued for a later + * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. + * Idle steering falls back to a woken follow-up turn. + * @param content - the steering content blocks. + * @param options - source and attached contexts. + */ + steer(content: ContentBlock[], options?: AliasSendOptions): void { + this.send(content, { ...options, target: 'next-step', wakeup: true }) + } + + /** + * Append detached model-facing context without running the model — the + * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins + * at the current log position unless the current tool batch is executing; + * then it waits FIFO until that batch settles and drains before turn close + * even when interrupted. Idle injection uses a one-shot turn and durability + * checkpoint. Disposal awaits idle checkpoints; flush failures report through + * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and durable model-hidden meta. + */ + inject(content: ContentBlock[], options?: AliasSendOptions): void { + this.send(content, { ...options, target: 'next-step', wakeup: false }) + } } ``` @@ -460,7 +560,7 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above, ## Interception decisions -Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes `context/message`; `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. +Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes an injected `user/message` (plugin/goal source); `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -470,8 +570,8 @@ interface HookContext { content: ContentBlock[] source: MessageSource /** - * Model placement. Absent or `separate` records an independent - * `context/message`; `prompt-prefix` prepends this context and a stable + * Model placement. Absent or `separate` records an independent injected + * `user/message`; `prompt-prefix` prepends this context and a stable * request delimiter to the same user-role message as its attached prompt. */ placement?: 'separate' | 'prompt-prefix' diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md index d45847ba0f..c0351f2f77 100644 --- a/docs/core-data-structures/goal.md +++ b/docs/core-data-structures/goal.md @@ -69,7 +69,7 @@ interface GoalView extends GoalSnapshot { ## Durable changes -Every mutation is a `context/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, and verbatim rendered content form one replay invariant. +Every mutation is a round-zero goal-sourced `user/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, and verbatim rendered content form one replay invariant. ```ts type-equiv /** Full-snapshot goal mutation retained in a model-visible context event. */ diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 7f61be2e73..d27f6fae88 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -9,7 +9,13 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site. ```ts type-equiv -/** Shared payload for ordinary and steering prompt messages. */ +/** + * Shared payload for user, injected-context, and steering prompt messages. A + * direct human prompt, a synthetic `agent.inject()` context, and mid-turn + * steering all project into the model transcript as verbatim user-role content; + * they are told apart by `source` (a non-`user` kind marks injected context), + * not by event type. `meta` carries durable model-hidden producer state. + */ interface PromptMessageData { /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ content: ContentBlock[] @@ -17,6 +23,15 @@ interface PromptMessageData { source: MessageSource /** Present only when prompt-prefix contexts were baked into `content`. */ envelope?: PromptMessageEnvelope + /** + * Opaque durable JSON state retained on the event but hidden from the model + * projection. It is the intended channel for a future framing directive (a + * producer declares the frame, a dedicated renderer applies it — see the + * deferred note in + * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), + * so the surface keeps projecting `content` verbatim rather than wrapping it. + */ + meta?: JsonValue } ``` @@ -46,29 +61,21 @@ interface SessionEventMap { 'step/start': { turn: number; step: number } /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (the queued message claimed for this turn). */ + /** + * A user-role message on the model-visible surface: a direct human prompt + * (the queued message claimed for this turn), a synthetic `agent.inject()` + * context (file-change notices, subdir AGENTS.md, skill content, cron + * notifications, …), or an admitted goal continuation round. All three + * project their `content` verbatim; `source` (with a non-`user` kind marking + * injected context) is the only channel that tells them apart. An idle + * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + */ 'user/message': PromptMessageData /** * Durable record of a prompt veto and its reason. It is log-only: the blocked * prompt never enters the model-visible surface, and its turn runs zero steps. */ '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 - * as a synthetic user-role message carrying `content` verbatim — NOT a - * user prompt. `meta` is durable JSON state omitted from the model - * projection; it is also the intended channel for any future framing - * directive (a producer declares the frame, a dedicated renderer applies it — - * see the deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ - 'context/message': { - content: ContentBlock[] - source: MessageSource - meta?: JsonValue - } /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** @@ -189,7 +196,7 @@ A proper discriminated union over `type` (not independent `type`/`data` unions), * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -223,7 +230,7 @@ For `assistant/message`, a present `sourceEventSeqs: []` is a complete known-emp ## Surface types -The five message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md). +The four message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `steering/message`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md). ### `SurfaceEventType` — the message-producing subset of event types @@ -237,7 +244,6 @@ type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'context/message' | 'steering/message' ``` @@ -248,7 +254,7 @@ type SurfaceEventType = * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * - `'append'`: added to the tail — normal path for user/assistant/tool/steering * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -455,7 +461,7 @@ declare class Session { - `user/message` → a user message carrying exact `content`; an optional envelope remains log-only display metadata. - `assistant/message` → an assistant message with the event's provider/model provenance and optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its usage/provenance, but a content-less assistant turn must not enter the provider transcript. - `tool/result` → a user message carrying a `tool-result` block. -- `context/message` → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. +- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. - `steering/message` → a user-role message carrying exact `content` at its chronological position; an optional envelope remains log-only display metadata. Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. @@ -479,11 +485,12 @@ interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } /** * An out-of-band context injection (`agent.inject()`) made while the agent - * was idle. The loop wraps the injected `context/message` in a one-shot turn - * (`turn/start` → `context/message` → `turn/end`) so every event in the log - * stays turn-enclosed — the durability/replay boundary is the turn, and a - * bare event between turns would otherwise be indistinguishable from a crash - * tail on reload. + * was idle. The loop wraps the injected `user/message` (a non-`user` source, + * plugin by default) in a one-shot turn (`turn/start` → `user/message` → + * `turn/end`) so every event in the log stays turn-enclosed — the + * durability/replay boundary is the turn, and a bare event between turns would + * otherwise be indistinguishable from a crash tail on reload. The trigger's + * `source` mirrors that message's producer. */ injection: { kind: 'injection'; source: MessageSource } } @@ -532,13 +539,13 @@ interface TurnEndReasonMap { ## The turn-enclosure invariant -Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn, and `appendOutOfBand()` similarly wraps an eligible log-only event when no turn is open. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). +Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, an idle `agent.inject()` wraps its `user/message` in a one-shot `injection` turn, and `appendOutOfBand()` similarly wraps an eligible log-only event when no turn is open. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). ## Plugin-contributed log-only events A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). -The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `context/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). +The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `user/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). ## Durability contract diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a04e633377..0825bbf348 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:365`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:246`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:330`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:291`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:197`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:341`](../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), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:352`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:329`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:269`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:477`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:309`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:358`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:374`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:388`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:442`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:403`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:342`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:287`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:415`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:453`](../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), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:464`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b5a3c60463..c94568f4d3 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -24,14 +24,13 @@ export type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'context/message' | 'steering/message' /** * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * - `'append'`: added to the tail — normal path for user/assistant/tool/steering * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -51,7 +50,7 @@ export type SurfaceOp = * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -79,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:389`](../packages/core/session/src/types.ts) ## Events @@ -151,7 +150,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -167,7 +166,7 @@ Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) ### `compact/*` @@ -221,33 +220,6 @@ Types: [ContentBlock](core-data-structures/core.md) Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts) -### `context/*` - -#### `context/message` — surface - -```ts persistence-catalog -/** - * In-session context injection (file-change notices, subdir AGENTS.md, - * skill content, cron notifications, …). Rendered into the derived history - * as a synthetic user-role message carrying `content` verbatim — NOT a - * user prompt. `meta` is durable JSON state omitted from the model - * projection; it is also the intended channel for any future framing - * directive (a producer declares the frame, a dedicated renderer applies it — - * see the deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ -'context/message': { - content: ContentBlock[] - source: MessageSource - meta?: JsonValue -} -``` - -Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) - -Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) - ### `hook/*` #### `hook/invoked` — log-only @@ -357,7 +329,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:40`](../packages/plan/plan-mode/s Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) ### `request/*` @@ -371,7 +343,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -427,7 +399,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages 'steering/message': PromptMessageData & { turn: number } ``` -Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) ### `step/*` @@ -438,7 +410,7 @@ Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -447,7 +419,7 @@ Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) ### `todo/*` @@ -460,7 +432,7 @@ Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) ### `tool/*` @@ -477,7 +449,7 @@ Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -521,7 +493,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) ### `turn/*` @@ -539,7 +511,7 @@ Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -555,15 +527,23 @@ Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts) ### `user/*` #### `user/message` — surface ```ts persistence-catalog -/** A user-visible prompt (the queued message claimed for this turn). */ +/** + * A user-role message on the model-visible surface: a direct human prompt + * (the queued message claimed for this turn), a synthetic `agent.inject()` + * context (file-change notices, subdir AGENTS.md, skill content, cron + * notifications, …), or an admitted goal continuation round. All three + * project their `content` verbatim; `source` (with a non-`user` kind marking + * injected context) is the only channel that tells them apart. An idle + * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + */ 'user/message': PromptMessageData ``` -Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 5c1bfbfdf3..ded9a0973e 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -23,12 +23,12 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | -| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `context/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | +| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | -| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | +| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 5fc21db2f5..46fd009ada 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -20,7 +20,7 @@ flowchart TD owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/code-dispatch"] post["tools/post-execute waterfall
accept, block, replace, add context"] final["tools/result synchronous notification
frozen authoritative outcome"] - context["Active-batch additionalContexts FIFO
context/message after recorded tool results"] + context["Active-batch additionalContexts FIFO
injected user/message after recorded tool results"] toolResult["Session event: tool/result
single model-facing outcome"] allResults["Tool batch settled
recorded tool/result events complete"] presentResult["UI completed card
presentResult(args, result)"] diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index e35d47cea0..7d014991ed 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -140,11 +140,11 @@ const SCENARIOS: Scenario[] = [ // Keyless, authored (like error-finish/cancel): deterministically forcing a // LIVE model to repeat one call three times is not a stable recording, so // the fixture scripts five identical todo_write calls and pins BOTH reminder - // tiers (gentle at 3, detailed at 5) as context/message in transcript and log. + // tiers (gentle at 3, detailed at 5) as injected user/message in transcript and log. { name: 'repeat-tool-guard', hasModelTurn: true, recorded: false }, // Authored replay: a root AGENTS.md pins the session prefix, then a read in // nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing - // context/message. Both AGENTS.md fixtures are symlinks to a sibling + // injected user/message. Both AGENTS.md fixtures are symlinks to a sibling // AGENTS.canonical.md, so this scenario also guards that discovery follows a // symlinked instruction file to its target's content. The scenario-specific // config keeps home/root discovery hermetic, and the resulting prefix needs @@ -220,7 +220,7 @@ const SCENARIOS: Scenario[] = [ // tool/code-dispatch events. Each overlay composes and pins its own header class. { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG }, // A nested fs dispatch inside run_code discovers workspace instructions. The - // context/message must follow the outer result while retaining workspace + // injected user/message must follow the outer result while retaining workspace // provenance, which proves Code Mode carries deferred tool context end to end. { name: 'code-mode-workspace-context', diff --git a/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl b/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl index 10ffb8c507..cb28e3c646 100644 --- a/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl +++ b/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl @@ -688,7 +688,7 @@ {"type":"turn/start","seq":686,"time":1783421455801,"data":{"turn":4,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"approval/policy","seq":687,"time":1783421455801,"data":{"policy":"never"}} {"type":"user/message","seq":688,"time":1783421455801,"data":{"content":[{"type":"text","text":"帮我创建一个 c.md"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":689,"time":1783421455802,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"} +{"type":"user/message","seq":689,"time":1783421455802,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"} {"type":"step/start","seq":690,"time":1783421455802,"data":{"turn":4,"step":1}} {"type":"request/header-delta","seq":691,"time":1783421455802,"data":{"system":{"keepStart":13,"keepEnd":0,"insert":["","Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation."]}}} {"type":"assistant/chunk","seq":692,"time":1783421456825,"data":{"turn":4,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -830,7 +830,7 @@ {"type":"turn/start","seq":828,"time":1783421478599,"data":{"turn":5,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"bash/sandbox-mode","seq":829,"time":1783421478599,"data":{"mode":"workspace-write"}} {"type":"user/message","seq":830,"time":1783421478599,"data":{"content":[{"type":"text","text":"帮我创建一个 d.md"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":831,"time":1783421478600,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"read-only\" to \"workspace-write\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"} +{"type":"user/message","seq":831,"time":1783421478600,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"read-only\" to \"workspace-write\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"} {"type":"step/start","seq":832,"time":1783421478600,"data":{"turn":5,"step":1}} {"type":"request/header-delta","seq":833,"time":1783421478600,"data":{"system":{"keepStart":12,"keepEnd":2,"insert":["Bash commands run under the \"workspace-write\" file sandbox."]}}} {"type":"assistant/chunk","seq":834,"time":1783421479489,"data":{"turn":5,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -1482,7 +1482,7 @@ {"type":"turn/start","seq":1480,"time":1783421524030,"data":{"turn":7,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"approval/policy","seq":1481,"time":1783421524030,"data":{"policy":"ask"}} {"type":"user/message","seq":1482,"time":1783421524030,"data":{"content":[{"type":"text","text":"帮我在 ~ 创建一个 f.md"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":1483,"time":1783421524030,"data":{"content":[{"type":"text","text":"The approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"} +{"type":"user/message","seq":1483,"time":1783421524030,"data":{"content":[{"type":"text","text":"The approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"} {"type":"step/start","seq":1484,"time":1783421524030,"data":{"turn":7,"step":1}} {"type":"request/header-delta","seq":1485,"time":1783421524030,"data":{"system":{"keepStart":13,"keepEnd":0,"insert":[]}}} {"type":"assistant/chunk","seq":1486,"time":1783421524940,"data":{"turn":7,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -1946,7 +1946,7 @@ {"type":"turn/start","seq":1944,"time":1783421552564,"data":{"turn":9,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"bash/sandbox-mode","seq":1945,"time":1783421552564,"data":{"mode":"danger-full-access"}} {"type":"user/message","seq":1946,"time":1783421552564,"data":{"content":[{"type":"text","text":"创建一个 h.md"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":1947,"time":1783421552564,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"workspace-write\" to \"danger-full-access\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"} +{"type":"user/message","seq":1947,"time":1783421552564,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"workspace-write\" to \"danger-full-access\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"} {"type":"step/start","seq":1948,"time":1783421552564,"data":{"turn":9,"step":1}} {"type":"request/header-delta","seq":1949,"time":1783421552564,"data":{"system":{"keepStart":12,"keepEnd":0,"insert":["Bash commands run under the \"danger-full-access\" file sandbox."]}}} {"type":"assistant/chunk","seq":1950,"time":1783421553289,"data":{"turn":9,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl index 8954fd6bad..d598b34e37 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl @@ -12,7 +12,7 @@ {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}} {"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"context/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} +{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} {"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -49,6 +49,6 @@ {"type":"step/start","seq":47,"time":0,"data":{"turn":3,"step":1}} {"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"context/message","seq":50,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} +{"type":"user/message","seq":50,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} {"type":"step/end","seq":51,"time":0,"data":{"turn":3,"step":1}} {"type":"turn/end","seq":52,"time":0,"data":{"turn":3,"reason":{"kind":"aborted"}}} diff --git a/examples/acp-agent/tests/goal.snapshot.ts b/examples/acp-agent/tests/goal.snapshot.ts index 4bb01acd16..23359f9982 100644 --- a/examples/acp-agent/tests/goal.snapshot.ts +++ b/examples/acp-agent/tests/goal.snapshot.ts @@ -83,6 +83,7 @@ describe('ACP same-session goal snapshot', () => { const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name) expect(calls).toEqual(['create_goal', 'get_goal']) const rounds = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal' + && event.data.source.round > 0 ? [event.data.source.round] : []) expect(rounds).toEqual([1, 2]) diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 281970523c..29bca35a09 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -87,7 +87,7 @@ {"type":"tool/call","seq":85,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}} {"type":"tool/code-dispatch","seq":86,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"./nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}} {"type":"tool/result","seq":87,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[85],"surfaceOp":"append"} -{"type":"context/message","seq":88,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} +{"type":"user/message","seq":88,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} {"type":"step/end","seq":89,"time":1783921767272,"data":{"turn":1,"step":1}} {"type":"step/start","seq":90,"time":1783921767272,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":91,"time":1783921768339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index bd13d3b146..5a51f98a42 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 6a78b05a65..04f138d06c 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl index c638389319..910bdfca68 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl @@ -63,7 +63,7 @@ {"type":"hook/invoked","seq":61,"time":1783352197968,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":62,"time":1783352197976,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":7.404540999999881}} {"type":"tool/result","seq":63,"time":1783352197976,"data":{"turn":1,"step":1,"callId":"call_00_HbCMzTslWBZTSphWN0z97382","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"} -{"type":"context/message","seq":64,"time":1783352197976,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} +{"type":"user/message","seq":64,"time":1783352197976,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352197977,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352197977,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352198981,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl index 46561a4760..921bc270d9 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"hook/invoked","seq":1,"time":1783352160546,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} {"type":"hook/result","seq":2,"time":1783352160564,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":17.45639600000004}} {"type":"user/message","seq":3,"time":1783352160564,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":4,"time":1783352160564,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1783352160564,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1783352160564,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"step/start","seq":6,"time":1783352160565,"data":{"turn":1,"step":1}} {"type":"request/header","seq":7,"time":1783352160566,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl index 9b58a769c5..deab3ab423 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl @@ -63,7 +63,7 @@ {"type":"hook/invoked","seq":61,"time":1783352229622,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":62,"time":1783352229632,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":9.27664199999981}} {"type":"tool/result","seq":63,"time":1783352229632,"data":{"turn":1,"step":1,"callId":"call_00_Q6wHtakaip2QNfIXaVJY5458","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"} -{"type":"context/message","seq":64,"time":1783352229633,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} +{"type":"user/message","seq":64,"time":1783352229633,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352229633,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352229633,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352230757,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl index c1f23a6b5e..ac319964f3 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"hook/invoked","seq":1,"time":1783352209687,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} {"type":"hook/result","seq":2,"time":1783352209706,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":19.49695100000008}} {"type":"user/message","seq":3,"time":1783352209707,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":4,"time":1783352209707,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1783352209707,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1783352209707,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"step/start","seq":6,"time":1783352209709,"data":{"turn":1,"step":1}} {"type":"request/header","seq":7,"time":1783352209710,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl index cbb52c1b08..381458957c 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl @@ -106,7 +106,7 @@ {"type":"sandbox/mode","seq":104,"time":1784518115842,"data":{"mode":"danger-full-access"}} {"type":"approval/policy","seq":105,"time":1783962244624,"data":{"policy":"never"}} {"type":"user/message","seq":106,"time":1783962244624,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":107,"time":1783962244624,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"} +{"type":"user/message","seq":107,"time":1783962244624,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"} {"type":"step/start","seq":108,"time":1783962244624,"data":{"turn":2,"step":1}} {"type":"request/header","seq":109,"time":1784000791271,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} {"type":"assistant/chunk","seq":110,"time":1783860671025,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl index a277f2e997..1f7345dc80 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl @@ -35,7 +35,7 @@ {"type":"tool/call","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} {"type":"todo/write","seq":34,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} {"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"callId":"call_3","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[33],"surfaceOp":"append"} -{"type":"context/message","seq":36,"time":0,"data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"} +{"type":"user/message","seq":36,"time":0,"data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"} {"type":"step/end","seq":37,"time":0,"data":{"turn":1,"step":3}} {"type":"step/start","seq":38,"time":0,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -58,7 +58,7 @@ {"type":"tool/call","seq":56,"time":0,"data":{"turn":1,"step":5,"callId":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} {"type":"todo/write","seq":57,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} {"type":"tool/result","seq":58,"time":0,"data":{"turn":1,"step":5,"callId":"call_5","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"} -{"type":"context/message","seq":59,"time":0,"data":{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"} +{"type":"user/message","seq":59,"time":0,"data":{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"} {"type":"step/end","seq":60,"time":0,"data":{"turn":1,"step":5}} {"type":"step/start","seq":61,"time":0,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 8293ea3abf..883e685a6d 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -12,7 +12,7 @@ {"type":"assistant/message","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} {"type":"tool/result","seq":12,"time":1783778297072,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"context/message","seq":13,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} +{"type":"user/message","seq":13,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} {"type":"step/end","seq":14,"time":1783778297072,"data":{"turn":1,"step":1}} {"type":"step/start","seq":15,"time":1783778297072,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":16,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index c7f5d48562..58256cbda4 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -151,7 +151,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p const events: SessionEvent[] = [...handle.agent.session.events] const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') - const workspaceContext = events.find(event => event.type === 'context/message' + const workspaceContext = events.find(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && typeof event.data.meta === 'object' && event.data.meta !== null && !Array.isArray(event.data.meta) diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 1626489bf5..a1ce4b6cc5 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -224,7 +224,7 @@ describe('headless stream-json snapshots', () => { .map(record => (record.data as JsonObject | undefined)?.name) expect(calls).toEqual(['create_goal', 'get_goal']) const goalChanges = records.filter((record) => { - if (record.type !== 'context/message') return false + if (record.type !== 'user/message') return false const data = record.data as JsonObject | undefined const meta = data?.meta as JsonObject | undefined return meta?.kind === 'goal/change' diff --git a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl index 05a1282a6f..5005192ec8 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl @@ -11,7 +11,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"context/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 7d04dfe952..7d6d9de97d 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -168,7 +168,7 @@ describe('bash tool through the agent loop', () => { expect(resultText(toolResult)).toContain('[exit code: 9]') }) - it('background: start ack → completion notice as context/message → task_output collects it', async () => { + it('background: start ack → completion notice as user/message → task_output collects it', async () => { // The task id is deterministic (a fresh TaskService counts per kind from 1), // so the script can name `bash-1` without threading a generated id. const adapter = new MockAdapter([ @@ -188,10 +188,12 @@ describe('bash tool through the agent loop', () => { expect(resultText(firstResult)).toBe('started background task bash-1') // The task settles on its own; the tool-tasks notice listener injects a - // durable context/message into the owning agent's session (settlement may - // race turn end, so poll for it). - await pollUntil(() => events(agent).some(event => event.type === 'context/message')) - const notice = findEvent(events(agent), 'context/message') + // durable plugin-sourced user/message into the owning agent's session + // (settlement may race turn end, so poll for it). + const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> => + e.type === 'user/message' && e.data.source.kind === 'plugin' + await pollUntil(() => events(agent).some(isNotice)) + const notice = events(agent).find(isNotice)! expect(notice.data.content.some( block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'), )).toBe(true) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index bbf4809466..f474f56485 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -42,7 +42,7 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } }) if (turn % 9 === 4) { - push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } }) + push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } }) } push({ type: 'step/start', data: { turn, step: 0 } }) const withTool = turn % 5 === 2 diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index ccb48a0161..d10bcea074 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -38,6 +38,14 @@ function materializeNode( ): ConversationNode { switch (event.type) { case 'user/message': + // Injected context (plugin/goal source) folds to a context node, not a + // user message; only a direct human prompt is a user node. + if (event.data.source.kind !== 'user') { + return { + kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source, + meta: event.data.meta, + } + } return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source } case 'assistant/message': return { @@ -46,11 +54,6 @@ function materializeNode( } case 'steering/message': return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source } - case 'context/message': - return { - kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source, - meta: event.data.meta, - } case 'tool/result': { const call = callIndex.get(String(event.data.callId)) return { @@ -63,7 +66,7 @@ function materializeNode( resultView, } } - /* v8 ignore next 2 -- defensive arm: fold output only carries the five + /* v8 ignore next 2 -- defensive arm: fold output only carries the four surface-eligible types, and each has a case above; reachable only if core adds an eligible type. */ default: diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts index 3214c44ee9..bb360e2a67 100644 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ b/packages/client/runtime/tests/fold-adapter.spec.ts @@ -40,7 +40,7 @@ describe('FoldAdapter', () => { ev.user(0, '用户'), ev.assistant(1, 0, '助手'), at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }), - at(3, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }), + at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }), ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'), ev.toolResult(5, 0, 'c1', '结果'), ] diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 3ea658b64d..1db86d38e4 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -563,7 +563,10 @@ describe('pressure measurement and retention', () => { const result = await compactIfNeeded(compact, session) expect(result).not.toBeNull() expect(prefix).toHaveLength(1) - expect(session.events.some(event => event.type === 'context/message')).toBe(false) + // The routed request prefix must not reach the surface as its own message + // (the compaction summary itself is an expected plugin-sourced checkpoint). + expect(session.events.some(event => event.type === 'user/message' + && event.data.content.some(block => block.type === 'text' && block.text.includes('p'.repeat(600))))).toBe(false) }) it('uses the latest logged request envelope without an AgentOptions override', async () => { @@ -959,7 +962,7 @@ describe('compaction region transaction', () => { const compact = service() const session = conversation(2) compact.mutateDuringSummary = () => { - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'concurrent surface mutation' }], source: { kind: 'plugin', plugin: 'test' }, }, { surfaceOp: 'append' }) diff --git a/packages/compact/compact/tests/tool-pairing.spec.ts b/packages/compact/compact/tests/tool-pairing.spec.ts index 5f48e7f99f..3de0473d57 100644 --- a/packages/compact/compact/tests/tool-pairing.spec.ts +++ b/packages/compact/compact/tests/tool-pairing.spec.ts @@ -96,23 +96,23 @@ describe('tool-pairing boundaries', () => { content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], provenance: { provider: 'mock', model: 'mock' }, }, SURFACE) - midStep.append('context/message', { + midStep.append('user/message', { content: [{ type: 'text', text: 'background update' }], source: { kind: 'plugin', plugin: 'test' }, }, SURFACE) midStep.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false, }, SURFACE) - expect(before(midStep, 'context/message')).toBe(false) - expect(after(midStep, 'context/message')).toBe(false) + expect(before(midStep, 'user/message')).toBe(false) + expect(after(midStep, 'user/message')).toBe(false) const free = new Session(SessionId('neutral-free')) - free.append('context/message', { + free.append('user/message', { content: [{ type: 'text', text: 'idle injection' }], source: { kind: 'user' }, }, SURFACE) - expect(before(free, 'context/message')).toBe(true) - expect(after(free, 'context/message')).toBe(true) + expect(before(free, 'user/message')).toBe(true) + expect(after(free, 'user/message')).toBe(true) }) }) diff --git a/packages/context/session-reference/src/projection.ts b/packages/context/session-reference/src/projection.ts index bbb2a2c739..dea29b38ee 100644 --- a/packages/context/session-reference/src/projection.ts +++ b/packages/context/session-reference/src/projection.ts @@ -57,7 +57,6 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected break } case 'tool/result': - case 'context/message': break /* v8 ignore next 2 -- SurfaceEventType is closed and every variant is handled above. */ default: diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index bb21cfab05..4496203dc7 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -61,7 +61,7 @@ function appendConversation(session: Session): void { { surfaceOp: 'append' }, ) session.append( - 'context/message', + 'user/message', { content: [{ type: 'text', text: 'workspace secret' }], source: { kind: 'plugin', plugin: 'workspace' } }, { surfaceOp: 'append' }, ) diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 96ea7165af..fcf9e36efc 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -64,7 +64,6 @@ function precedingMessageTime(agent: Agent): number | undefined { case 'user/message': case 'assistant/message': case 'tool/result': - case 'context/message': case 'steering/message': return event.time default: @@ -79,7 +78,7 @@ function precedingMessageTime(agent: Agent): number | undefined { function precedingStepContextTime(agent: Agent, turn: number): number | undefined { for (const event of [...agent.session.events].reverse()) { if (event.type === 'turn/start' && event.data.turn === turn) return undefined - if (event.type === 'context/message' + if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === name) { return event.time @@ -91,7 +90,7 @@ function precedingStepContextTime(agent: Agent, turn: number): number | undefine /** Find this plugin's latest durable injection, including a shadowed surface event. */ function latestInjectionTime(agent: Agent): number | undefined { for (const event of [...agent.session.events].reverse()) { - if (event.type === 'context/message' + if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === name) { return event.time diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 45fdb48cba..aa8f0418dd 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -48,7 +48,7 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa /** Validate one plugin-attributed time reading against its session position and timestamp. */ function validateReading( history: readonly SessionEvent[], - event: SessionEvent<'context/message'>, + event: SessionEvent<'user/message'>, fail: InvariantFailure, ): void { const [block] = event.data.content @@ -84,7 +84,7 @@ function validateReading( /** Validate all package-owned readings already present in one session. */ function validateSession(session: Session, fail: InvariantFailure): void { for (const [index, event] of session.events.entries()) { - if (event.type !== 'context/message' + if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) continue validateReading(session.events.slice(0, index), event, fail) @@ -97,7 +97,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] - if (event.type !== 'context/message' + if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) return validateReading(session.events, event, fail) diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index cd65f1aa3d..855303b295 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -17,7 +17,7 @@ async function setup(): Promise { function event(text: string, time = SECOND + 456, content?: unknown[]): SessionEvent { return { - type: 'context/message', + type: 'user/message', seq: 0, time, data: { @@ -56,7 +56,7 @@ function preparing(turn: number, step: number): Session { } function appendReading(session: Session, text: string): void { - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'time-context' }, }, { surfaceOp: 'append' }) @@ -162,7 +162,7 @@ describe('time-context invariants', () => { it('ignores context messages owned by another package', async () => { const ctx = await setup() - const other = event('unrelated') as SessionEvent<'context/message'> + const other = event('unrelated') as SessionEvent<'user/message'> other.data.source = { kind: 'plugin', plugin: 'other' } expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow() other.data.source = { kind: 'user' } diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index 2a0c06fe51..13edcfb036 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -48,7 +48,7 @@ describe('time-context through a real headless cordis.yml', () => { expect(stderr).not.toContain('UNHANDLED') expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) - const contexts = events.filter(event => event.type === 'context/message') + const contexts = events.filter(event => event.type === 'user/message') const starts = events.filter(event => event.type === 'step/start') expect(contexts).toHaveLength(2) expect(starts).toHaveLength(2) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index d1cd07207e..26bb5d7a9d 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -43,9 +43,10 @@ function sessionAgent(session: Session, id = 'agent'): Agent { status: 'running', ctx: new Context(), send() {}, + followup() {}, steer() {}, inject(content, options) { - session.append('context/message', { + session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, }, { surfaceOp: 'append' }) @@ -66,7 +67,7 @@ function openMessageTurn(session: Session, turn: number): void { function contextTexts(session: Session): string[] { const texts: string[] = [] for (const event of session.events) { - if (event.type === 'context/message' + if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'time-context') { texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '') @@ -151,8 +152,8 @@ describe('durable step context', () => { + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.', ]) const event = session.events.at(-1) - expect(event?.type).toBe('context/message') - if (event?.type !== 'context/message') throw new Error('missing time context') + expect(event?.type).toBe('user/message') + if (event?.type !== 'user/message') throw new Error('missing time context') expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' }) expect(event.surfaceOp).toBe('append') }) @@ -230,10 +231,10 @@ describe('durable step context', () => { const original = new Session(SessionId('seed-source')) openMessageTurn(original, 1) await fire(ctx, sessionAgent(original), 1, 1) - const user = original.events.find(event => event.type === 'user/message') - const reading = original.events.find(event => event.type === 'context/message') + const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user') + const reading = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') if (user === undefined || reading === undefined) throw new Error('missing source surface events') - original.append('context/message', { + original.append('user/message', { content: [{ type: 'text', text: 'compacted history' }], source: { kind: 'plugin', plugin: 'compact-basic' }, }, { @@ -292,7 +293,7 @@ describe('durable step context', () => { openMessageTurn(session, 1) let ordinarySawContext = false ctx.on('agent/pre-step', (subject) => { - ordinarySawContext = subject.session.events.some(event => event.type === 'context/message') + ordinarySawContext = subject.session.events.some(event => event.type === 'user/message') }) await fire(ctx, agent, 1, 1) @@ -401,7 +402,8 @@ describe('real agent-loop request history', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(2) - const contexts = agent.session.events.filter(event => event.type === 'context/message') + const contexts = agent.session.events.filter( + (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin') const starts = agent.session.events.filter(event => event.type === 'step/start') expect(contexts).toHaveLength(adapter.requests.length) expect(starts).toHaveLength(adapter.requests.length) diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 66b70f639d..61db3f527b 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -145,7 +145,7 @@ function visibleInstructionChanges( const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map() for (const [seq, event] of agent.session.events.entries()) { - if (event.type !== 'context/message' || !isWorkspaceContextSource(event.data.source)) continue + if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue const changes = workspaceInstructionChanges(event.data.meta) for (const change of changes) { const waiting = pending.get(change.scope) @@ -281,7 +281,7 @@ export function observeInstructionSessionEvent( if (pending === undefined) return switch (event.type) { - case 'context/message': { + case 'user/message': { if (!isWorkspaceContextSource(event.data.source)) return for (const change of workspaceInstructionChanges(event.data.meta)) { const waiting = pending.get(change.scope) diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index 9341eb33d0..f80ee5acde 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -107,15 +107,15 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode await waitForIdle(live.ctx, live.agent) const events = [...live.agent.session.events] - const update = events.find(event => event.type === 'context/message' + const update = events.find(event => event.type === 'user/message' && typeof event.data.meta === 'object' && event.data.meta !== null && !Array.isArray(event.data.meta) && event.data.meta.kind === 'workspace-instructions') - expect(update?.type === 'context/message' && update.data.meta).toMatchObject({ + expect(update?.type === 'user/message' && update.data.meta).toMatchObject({ changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) - const updateText = update?.type === 'context/message' + const updateText = update?.type === 'user/message' ? update.data.content.filter(block => block.type === 'text').map(block => block.text).join('') : '' expect(updateText).toContain('Updated instructions from: AGENTS.md') diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index c26be1a58e..c96cee8b38 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -175,9 +175,10 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { session, status: 'idle', send() {}, + followup() {}, steer() {}, inject(content, options) { - session.append('context/message', { + session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, ...options?.meta !== undefined ? { meta: options.meta } : {}, @@ -219,7 +220,7 @@ function workspaceChangeContext(scope: string, digest: string): HookContext { function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined { let lastSeq: number | undefined for (const context of result.additionalContexts ?? []) { - lastSeq = agent.session.append('context/message', { + lastSeq = agent.session.append('user/message', { content: context.content, source: context.source, ...context.meta !== undefined ? { meta: context.meta } : {}, @@ -971,7 +972,7 @@ describe('workspace context request injection', () => { const second = await composeBaselinePrefix(ctx, agent) expect(second).toEqual(first) - expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0) + expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0) expect(derivedText(agent)).toContain('repo rule') } finally { await rm(root, { recursive: true, force: true }) @@ -1143,7 +1144,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0) + expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0) expect(derivedText(agent)).not.toContain('workspace-context:') } finally { await rm(root, { recursive: true, force: true }) @@ -1711,12 +1712,12 @@ describe('dynamic nested workspace context injection', () => { agent.send([{ type: 'text', text: 'read and abort' }]) await agent.whenIdle() - expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) agent.send([{ type: 'text', text: 'retry the read' }]) await agent.whenIdle() - const contexts = agent.session.events.filter(event => event.type === 'context/message') + const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') // The aborted batch drained its accepted context before step close, so the // retry sees durable history without producing a duplicate instruction. expect(contexts).toHaveLength(1) @@ -2490,10 +2491,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) appendAdditionalContexts(agent, first) - const resumed = { - ...agent, - session: new Session(agent.session.id, [...agent.session.events], agent.session.header), - } + const resumed = stubAgent(root, [...agent.session.events]) const afterResume = await ctx.tools.execute({ signal: testToolSignal, @@ -2531,11 +2529,11 @@ describe('dynamic nested workspace context injection', () => { await composeBaselinePrefix(ctx, resumed) - const update = resumed.session.events.findLast(event => event.type === 'context/message') - expect(update?.type === 'context/message' && update.data.meta).toMatchObject({ + const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(update?.type === 'user/message' && update.data.meta).toMatchObject({ changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) - expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume') + expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2681,7 +2679,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) - agent.session.append('context/message', { + agent.session.append('user/message', { content: [ { type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' }, { type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' }, @@ -2698,12 +2696,12 @@ describe('dynamic nested workspace context injection', () => { ], }, }, { surfaceOp: 'append' }) - agent.session.append('context/message', { + agent.session.append('user/message', { content: [{ type: 'text', text: 'stale metadata version' }], source: { kind: 'plugin', plugin: 'workspace-context' }, meta: { kind: 'workspace-instructions', version: 0, changes: [] }, }, { surfaceOp: 'append' }) - agent.session.append('context/message', { + agent.session.append('user/message', { content: [{ type: 'text', text: 'foreign plugin context' }], source: { kind: 'plugin', plugin: 'other' }, meta: { @@ -3216,14 +3214,14 @@ describe('workspace context pending state', () => { path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one', }]])) - const unrelated = agent.session.append('context/message', { + const unrelated = agent.session.append('user/message', { content: [], source: { kind: 'plugin', plugin: 'other' }, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, unrelated, pending, versions) expect(pending.get(agent.session)?.has('pkg')).toBe(true) const otherContext = workspaceChangeContext('other', 'other') - const otherWorkspaceEvent = agent.session.append('context/message', { + const otherWorkspaceEvent = agent.session.append('user/message', { content: otherContext.content, source: otherContext.source, ...otherContext.meta !== undefined ? { meta: otherContext.meta } : {}, @@ -3232,7 +3230,7 @@ describe('workspace context pending state', () => { expect(pending.get(agent.session)?.has('pkg')).toBe(true) const context = workspaceChangeContext('pkg', 'one') - const confirmed = agent.session.append('context/message', { + const confirmed = agent.session.append('user/message', { content: context.content, source: context.source, ...context.meta !== undefined ? { meta: context.meta } : {}, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 3b9111f558..f5872f5fcc 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -843,6 +843,27 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A step or turn errored. The loop reports a failure here (plus the logger)\n * even when the error has no in-turn position for a session `error` event.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A step or turn errored.', }, + { + name: 'agent/inbox/dequeue', + mode: 'emit', + signature: '\'agent/inbox/dequeue\'(this: Scoped, agent: Agent, info: InboxItemInfo): void', + jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param info - the claimed item\'s accepted content, source, contexts, steering, and wakeup facts.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps.', + }, + { + name: 'agent/inbox/discard', + mode: 'emit', + signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, items: InboxItemInfo[]): void', + jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: '`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them.', + }, + { + name: 'agent/inbox/enqueue', + mode: 'emit', + signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, info: InboxItemInfo): void', + jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `info` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param info - the accepted content, source, contexts, steering, and wakeup facts.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', + }, { name: 'agent/post-step', mode: 'serial', @@ -864,13 +885,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default. A listener wrapping a\n * downstream `allow` must preserve its `content` and `additionalContexts`\n * unless it intentionally replaces them. The signal controls only this turn;\n * listeners may cooperate with it but must not retain it to control another\n * turn. Steering messages do not dispatch this event; they join an open turn\n * at a steering checkpoint.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.', }, - { - name: 'agent/queued', - mode: 'emit', - signature: '\'agent/queued\'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void', - jsDoc: '/**\n * Detached, frozen content entered the agent\'s inbox. Source defaults have\n * already been applied, so these are the exact values retained for the log.\n * @param agent - the agent whose inbox received the message.\n * @param content - the accepted content blocks retained by the inbox.\n * @param info - the accepted source, contexts, and whether it entered as steering.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'Detached, frozen content entered the agent\'s inbox.', - }, { name: 'agent/request', mode: 'waterfall', @@ -1127,14 +1141,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ - { - name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n}', - }, - { - name: 'AgentCancelCause', - declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n};', - }, { name: 'AgentFactory', declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise;\n}', @@ -1147,10 +1153,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n}', }, - { - name: 'AgentStatus', - declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';', - }, { name: 'ApprovalOutcome', declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';', @@ -1451,10 +1453,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'HookContext', declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n meta?: JsonValue;\n}', }, - { - name: 'InjectOptions', - declaration: 'export interface InjectOptions extends Omit {\n meta?: JsonValue;\n}', - }, { name: 'InvariantFailure', declaration: 'export type InvariantFailure = (message: string) => never;', @@ -1525,7 +1523,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptMessageData', - declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n}', + declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n}', }, { name: 'PromptMessageEnvelope', @@ -1627,6 +1625,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ReasoningBlock', declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}', }, + { + name: 'RequestHeaderReason', + declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';', + }, { name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', @@ -1659,17 +1661,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ScopeKey', declaration: 'export type ScopeKey = object;', }, - { - name: 'SendOptions', - declaration: 'export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n}', - }, { name: 'SessionEvent', declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: Req /* …truncated — full shape in source */', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}', }, { name: 'SessionEventReadRequest', @@ -1881,7 +1879,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SurfaceEventType', - declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'context/message\' | \'steering/message\';', + declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';', }, { name: 'SurfaceOp', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 1b89f288a8..a3b834394b 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -Each concrete `send()` materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; its contexts are the prompt waterfall's default additional contexts and therefore materialize only after admission. Absent or `separate` placement appends an independent `context/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append. +The unified `send()` primitive materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON record, then routes it by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO (waking the driver unless `wakeup: false`); if claimed, it is the sole ordinary message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. A running `next-step`/wakeup `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `next-step`/no-wakeup `inject()` bypasses the FIFOs and appends durable context directly: an open-turn injection uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 91efbf7782..d118fb3063 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -8,8 +8,8 @@ import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { Agent } from '@deepseek-ai/dsh-agent' +import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, InboxItemInfo, SendOptions } from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' @@ -100,7 +100,7 @@ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): * the loop driver. Everything observable happens through session events and * the agent/* event taxonomy — plugins never need this class. */ -export class ReactLoopAgent implements Agent { +export class ReactLoopAgent extends Agent { /** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */ readonly #inbox = new Inbox() @@ -161,6 +161,7 @@ export class ReactLoopAgent implements Agent { public readonly session: Session, maxParallelToolCalls: number, ) { + super() this.maxParallelToolCalls = maxParallelToolCalls const { promise, resolve } = Promise.withResolvers() this.disposed = promise @@ -190,25 +191,25 @@ export class ReactLoopAgent implements Agent { for (const resolve of waiters) resolve() } - private resolveSource(options?: SendOptions): MessageSource { - return options?.source ?? { kind: 'user' } - } - /** * Accept one public message payload as a detached record. Lossless-JSON * materialization reads every nested field once; deep freeze prevents later * caller mutation before an inbox or deferred-injection queue drains it. */ - private acceptMessage(content: ContentBlock[], options?: SendOptions): InboxMessage { - const source = this.resolveSource(options) + private acceptMessage(content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions): InboxMessage { const contexts = options?.contexts ?? [] - const accepted = snapshotJsonValue({ content, source, contexts }) + const accepted = snapshotJsonValue({ content, source, contexts, wakeup }) if (accepted === undefined) { throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable') } return deepFreeze(accepted) } + /** Build the `agent/inbox/*` payload for one accepted item. */ + private inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo { + return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } + } + /** Detach one context before it can outlive its caller in the active-batch FIFO. */ private acceptContext(context: HookContext): HookContext { const accepted = snapshotJsonValue(context) @@ -225,24 +226,26 @@ export class ReactLoopAgent implements Agent { send(content: ContentBlock[], options?: SendOptions): void { this.assertNotDisposed() - const accepted = this.acceptMessage(content, options) - this.#inbox.enqueue(accepted) - const info = { source: accepted.source, contexts: accepted.contexts, steering: false } as const - agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) + const target = options?.target ?? 'next-turn' + const wakeup = options?.wakeup ?? true + // next-step/no-wakeup is injection: durable context without running the model. + if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return } + // next-step/wakeup is steering into the running turn; idle falls back to a + // woken follow-up turn (there is no active turn to attach to). + const steering = target === 'next-step' && this._status === 'running' + const source = options?.source ?? { kind: 'user' } + const accepted = this.acceptMessage(content, source, wakeup, options) + if (steering) { + this.#inbox.steer(accepted) + } else { + this.#inbox.enqueue(accepted, wakeup) + } + agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', this.inboxInfo(accepted, steering)) } - steer(content: ContentBlock[], options?: SendOptions): void { - this.assertNotDisposed() - if (this._status !== 'running') { this.send(content, options); return } - const accepted = this.acceptMessage(content, options) - this.#inbox.steer(accepted) - const info = { source: accepted.source, contexts: accepted.contexts, steering: true } as const - agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) - } - - inject(content: ContentBlock[], options?: InjectOptions): void { - this.assertNotDisposed() - const source = this.resolveSource(options) + /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ + private injectContext(content: ContentBlock[], options?: SendOptions): void { + const source = options?.source ?? { kind: 'plugin', plugin: '' } const context = { content, source, @@ -257,7 +260,7 @@ export class ReactLoopAgent implements Agent { this.deferredInjections.push(accepted) return } - this.session.append('context/message', accepted, { surfaceOp: 'append' }) + this.session.append('user/message', accepted, { surfaceOp: 'append' }) return } // No turn open: wrap the injection in a one-shot turn so every event stays @@ -269,7 +272,7 @@ export class ReactLoopAgent implements Agent { // are contained by Session and cannot create a false append failure. try { this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - this.session.append('context/message', context, { surfaceOp: 'append' }) + this.session.append('user/message', context, { surfaceOp: 'append' }) } finally { // Close the turn if turn/start made it into the log. A pre-commit veto // must escape rather than being mistaken for a committed turn/end. @@ -301,7 +304,7 @@ export class ReactLoopAgent implements Agent { private drainDeferredInjections(): void { const pending = this.deferredInjections.splice(0) for (const accepted of pending) { - this.session.append('context/message', accepted, { surfaceOp: 'append' }) + this.session.append('user/message', accepted, { surfaceOp: 'append' }) } } @@ -325,10 +328,14 @@ export class ReactLoopAgent implements Agent { } } - cancel(cause?: AgentCancelCause): void { + cancel(cause?: AgentCancelCause, options?: CancelOptions): void { const resolvedCause = cause ?? { kind: 'user' } + const keepInbox = options?.keepInbox ?? false const cancellation = this.turnCancellation - const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering) + // keepInbox preserves pending work, so un-started items must not arm the + // pre-run cancel path that would otherwise drop the next queued turn. + const preRun = !keepInbox && cancellation === undefined + && (this.#inbox.hasQueued || this.#inbox.hasSteering) if (cancellation !== undefined || preRun) { if (preRun) this.preRunCancelled = true // Coordination consumers must update their own state before this call @@ -336,9 +343,18 @@ export class ReactLoopAgent implements Agent { // contained by the fused dispatcher and cannot veto cancellation. agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause) } - // Clear work already present before abort observers run. A replacement - // synchronously enqueued by an observer belongs to the next turn. - this.#inbox.clear() + if (!keepInbox) { + // Snapshot before clearing so the discard notification carries the exact + // dropped items; a replacement synchronously enqueued by an + // `agent/cancel-requested` observer belongs to the next turn, not here. + const discarded = this.#inbox.pending() + // Clear work already present before abort observers run. + this.#inbox.clear() + if (discarded.length > 0) { + const items = discarded.map(({ message, steering }) => this.inboxInfo(message, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) + } + } cancellation?.request(resolvedCause) } diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index 6c8a20e3d1..a5ddd87b1f 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -1,7 +1,7 @@ /** * Per-agent message inbox: queued and steering FIFOs. Purely an in-memory - * mechanism of the loop driver — the public surface is `Agent.send()` and - * `Agent.steer()`. + * mechanism of the loop driver — the public surface is `Agent.send()` and its + * fixed-preset aliases. * * @module dsh-agent-loop/inbox */ @@ -14,12 +14,14 @@ export interface InboxMessage { content: ContentBlock[] source: MessageSource contexts: HookContext[] + /** Whether the item is marked to wake the driver or force a continuation. */ + wakeup: boolean } /** * Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO * (drained between steps of a running turn). Purely an in-memory mechanism of - * the loop — the public surface is `Agent.send()` / `Agent.steer()`. + * the loop — the public surface is `Agent.send()` and its aliases. */ export class Inbox { private queuedMessages: InboxMessage[] = [] @@ -37,18 +39,21 @@ export class Inbox { } /** - * Add a message to the queued FIFO and wake a parked {@link waitForQueued}. + * Add a message to the queued FIFO, waking a parked {@link waitForQueued} + * unless the item opted out. A non-waking item still runs once any woken + * item or later wakeup drives the parked loop. * @param message - the message to queue for the next turn start. + * @param wake - whether to wake a parked idle wait (default true). */ - enqueue(message: InboxMessage): void { + enqueue(message: InboxMessage, wake = true): void { this.queuedMessages.push(message) - this.wakeup?.() + if (wake) this.wakeup?.() } /** * Add a message to the steering FIFO. Deliberately no wakeup: steering is * drained between steps of a running turn, never by the idle wait — - * `Agent.steer()` on an idle agent falls back to `send()` instead. + * `Agent.steer()` on an idle agent falls back to a woken follow-up instead. * @param message - the message to inject between steps of the running turn. */ steer(message: InboxMessage): void { @@ -71,6 +76,18 @@ export class Inbox { return this.steeringMessages.splice(0) } + /** + * Snapshot the pending items (queued then steering, FIFO order) without + * removing them — the discard notification's payload source. + * @returns the pending items paired with whether each is steering. + */ + pending(): { message: InboxMessage; steering: boolean }[] { + return [ + ...this.queuedMessages.map(message => ({ message, steering: false })), + ...this.steeringMessages.map(message => ({ message, steering: true })), + ] + } + /** * Discard all pending messages (queued + steering) without delivering them — * used by `cancel()`, which drops un-started work rather than draining it into diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 1cfd913b77..c13fa5a74f 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -10,7 +10,7 @@ import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFai import { isDeepStrictEqual } from 'node:util' import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent' -import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' +import type { AgentEventDispatch, ContinuationDecision, HookContext, InboxItemInfo, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { createTransmissionLog, recordRequestHeader } from './request-log.ts' @@ -19,9 +19,14 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' -import type { Inbox } from './inbox.ts' +import type { Inbox, InboxMessage } from './inbox.ts' import type { TurnCancellation } from './cancellation.ts' +/** Build the `agent/inbox/dequeue` payload for one claimed inbox item. */ +function inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo { + return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } +} + /** Normalize thrown values while preserving an existing error code. */ function toError(error: unknown): RequestError { return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) @@ -279,10 +284,11 @@ async function runTurn( const drainSteering = (): boolean => { const messages = handle.inbox.drainSteering() for (const message of messages) { + events.emit('agent/inbox/dequeue', inboxInfo(message, true)) const prepared = preparePromptMessage(message.content, message.source, message.contexts) session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' }) for (const context of prepared.separateContexts) { - session.append('context/message', { + session.append('user/message', { content: context.content, source: context.source, ...context.meta === undefined ? {} : { meta: context.meta }, @@ -296,6 +302,7 @@ async function runTurn( const message = handle.inbox.dequeueQueued() /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ if (!message) throw new Error('runTurn invariant violated: no queued message at turn start') + events.emit('agent/inbox/dequeue', inboxInfo(message, false)) const trigger: TurnTrigger = { kind: 'message', source: message.source } let reason: TurnEndReason = { kind: 'completed' } @@ -538,7 +545,7 @@ async function runTurn( // A continuation reason becomes next-step steering. if (decision.action === 'continue' && decision.reason) { - handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [] }) + handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [], wakeup: true }) } let shouldContinue = decision.action === 'continue' diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 2cb3191f83..608f1bad60 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -139,7 +139,7 @@ describe('Agent', () => { agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } }) expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.at(-1)!.type).toBe('context/message') + expect(agent.session.events.at(-1)!.type).toBe('user/message') // Close the turn; now inject must wrap its own one-shot injection turn. agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -151,6 +151,16 @@ describe('Agent', () => { expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed }) + it('inject() defaults its source to an empty plugin, never user', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + agent.inject([{ type: 'text', text: 'no explicit source' }]) + const injected = agent.session.events.at(-1)! + expect(injected.type === 'user/message' && injected.data.source).toEqual({ kind: 'plugin', plugin: '' }) + }) + it('idle inject() contains a failing flush (logs, does not throw into the caller)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -202,7 +212,7 @@ describe('Agent', () => { expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow() const types = agent.session.events.map(e => e.type) - expect(types).toEqual(['turn/start', 'context/message', 'turn/end']) // balanced + expect(types).toEqual(['turn/start', 'user/message', 'turn/end']) // balanced await new Promise(r => setTimeout(r, 10)) expect(flushes).toBe(1) // checkpoint fired despite the throwing turn/end listener }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 162d3b552a..11dc77ed5d 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -98,6 +98,25 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) }) + it('cancel({ keepInbox: true }) preserves queued work and emits no discard', async () => { + const adapter = new MockAdapter([textResponse('reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const discards: unknown[] = [] + ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) }) + + // Queue a turn WITHOUT waking the driver, so it sits in the inbox. + agent.send([{ type: 'text', text: 'preserved' }], { target: 'next-turn', wakeup: false }) + // keepInbox cancel: no active turn, work preserved, no discard event. + agent.cancel({ kind: 'user' }, { keepInbox: true }) + expect(discards).toEqual([]) + + // The preserved item still runs once the driver is woken by a later send. + send(agent, 'wake it') + await waitForIdle(ctx, agent) + expect(userTexts(agent)).toEqual(['preserved', 'wake it']) + }) + it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 3a9ca87d68..3b7aa8170c 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -275,7 +275,9 @@ describe('abort during tool execution ends the turn', () => { order.push(`tool/result:${event.data.callId}:${outcome}`) break } - case 'context/message': order.push('context/message'); break + // Injected context is a plugin-sourced user/message; the direct human + // prompt (user source) is not tracked in this ordering. + case 'user/message': if (event.data.source.kind !== 'user') order.push('context/message'); break case 'steering/message': order.push('steering/message'); break case 'step/end': order.push('step/end'); break case 'turn/end': { @@ -354,13 +356,14 @@ describe('abort during tool execution ends the turn', () => { await waitForIdle(ctx, agent) const events = [...agent.session.events] + const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user' expect(events - .filter(event => event.type === 'tool/result' || event.type === 'context/message' + .filter(event => event.type === 'tool/result' || isInjected(event) || event.type === 'step/end' || event.type === 'turn/end') - .map(event => event.type)) + .map(event => isInjected(event) ? 'context/message' : event.type)) .toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end']) expect(events - .filter(event => event.type === 'context/message') + .filter(isInjected) .map(event => event.data.content)) .toEqual([ [{ type: 'text', text: 'accepted before abort' }], @@ -410,12 +413,13 @@ describe('abort during tool execution ends the turn', () => { await waitForIdle(ctx, agent) const events = [...agent.session.events] + const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user' expect(events - .filter(event => event.type === 'tool/result' || event.type === 'context/message' + .filter(event => event.type === 'tool/result' || isInjected(event) || event.type === 'step/end' || event.type === 'turn/end') - .map(event => event.type)) + .map(event => isInjected(event) ? 'context/message' : event.type)) .toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end']) - expect(events.find(event => event.type === 'context/message')?.data.content) + expect(events.find(isInjected)?.data.content) .toEqual([{ type: 'text', text: 'accepted after first result' }]) }) @@ -456,7 +460,7 @@ describe('abort during tool execution ends the turn', () => { await fiber.dispose() expect(agent.session.events - .filter(event => event.type === 'context/message') + .filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user') .map(event => event.data.content)) .toEqual([ [{ type: 'text', text: 'accepted before disposal' }], @@ -507,7 +511,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'start a text-only turn') await waitForIdle(ctx, agent) - expect(agent.session.events.find(event => event.type === 'context/message')?.data.content) + expect(agent.session.events.find((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')?.data.content) .toEqual([{ type: 'text', text: 'new turn context' }]) expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context') }) @@ -763,7 +767,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }]) }) - it('agent/queued carries the resolved source; steering/message records its source', async () => { + it('agent/inbox/enqueue carries the resolved source; steering/message records its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -778,7 +782,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { })) const queuedSources: { source: MessageSource; contexts: HookContext[]; steering: boolean }[] = [] - ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info)) + ctx.on('agent/inbox/enqueue', (_agent, info) => void queuedSources.push({ source: info.source, contexts: info.contexts, steering: info.steering })) send(agent, 'go') // no explicit source → default {kind:'user'} must be visible await waitForIdle(ctx, agent) @@ -800,11 +804,11 @@ describe('adapter registration, routing, and accepted-input ownership', () => { let notifiedContent: ContentBlock[] | undefined let notifiedSource: MessageSource | undefined let notifiedContexts: HookContext[] | undefined - ctx.on('agent/queued', (subject, acceptedContent, info) => { + ctx.on('agent/inbox/enqueue', (subject, 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 + notifiedContent = info.content notifiedSource = info.source notifiedContexts = info.contexts }) @@ -863,9 +867,9 @@ describe('adapter registration, routing, and accepted-input ownership', () => { let notifiedContent: ContentBlock[] | undefined let notifiedSource: MessageSource | undefined let notifiedContexts: HookContext[] | undefined - ctx.on('agent/queued', (subject, acceptedContent, info) => { + ctx.on('agent/inbox/enqueue', (subject, info) => { if (subject !== agent || !info.steering) return - notifiedContent = acceptedContent + notifiedContent = info.content notifiedSource = info.source notifiedContexts = info.contexts }) @@ -951,7 +955,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { expect(request).not.toContain('caller-mutated-steering-context-without-meta') const steeringIndex = agent.session.events.findIndex(event => event.type === 'steering/message') - const contextIndex = agent.session.events.findIndex(event => event.type === 'context/message' + const contextIndex = agent.session.events.findIndex(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'steering-context') expect(steeringIndex).toBeGreaterThanOrEqual(0) expect(contextIndex).toBe(steeringIndex + 1) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 7d4e79238e..700a4ac017 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -47,7 +47,7 @@ describe('inbox acceptance', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let queued = 0 - ctx.on('agent/queued', () => { queued += 1 }) + ctx.on('agent/inbox/enqueue', () => { queued += 1 }) expect(() => { agent.send([{ type: 'text', text: 'first', bad: 1n } as never]) diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 99cae1ae77..91858e7050 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Inbox } from '../src/inbox.ts' function message(text: string) { - return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [] } + return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true } } function resolverPair() { @@ -25,6 +25,32 @@ describe('Inbox', () => { expect(inbox.dequeueQueued()).toBeUndefined() }) + it('enqueue(msg, false) queues without waking a parked waiter', async () => { + const inbox = new Inbox() + let woke = false + const waiter = inbox.waitForQueued(new Promise(() => {})).then(() => { woke = true }) + inbox.enqueue(message('quiet'), false) + // The item is queued, but the parked waiter was not resolved by it. + expect(inbox.hasQueued).toBe(true) + await Promise.resolve() + expect(woke).toBe(false) + // A later waking enqueue resolves the same waiter. + inbox.enqueue(message('loud')) + await waiter + expect(woke).toBe(true) + }) + + it('pending() snapshots queued then steering without removing them', () => { + const inbox = new Inbox() + inbox.enqueue(message('q')) + inbox.steer(message('s')) + const pending = inbox.pending() + expect(pending.map(p => p.steering)).toEqual([false, true]) + // Snapshot does not drain the FIFOs. + expect(inbox.hasQueued).toBe(true) + expect(inbox.hasSteering).toBe(true) + }) + it('pushes and drains steering messages separately from queued', () => { const inbox = new Inbox() inbox.steer(message('steer')) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 42a2808170..b225e67b1c 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -87,7 +87,7 @@ describe('agent/prompt-submit', () => { expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original') }) - it('allow with additionalContexts injects separate context/message events into the turn', async () => { + it('allow with additionalContexts injects separate injected-context user messages into the turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -107,12 +107,12 @@ describe('agent/prompt-submit', () => { await waitForIdle(ctx, agent) const log = events(agent) - const userMsg = log.find(e => e.type === 'user/message') - const ctxMsg = log.find(e => e.type === 'context/message') + const userMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'user') + const ctxMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(userMsg).toBeDefined() - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }]) - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta) + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }]) + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.meta).toEqual(meta) const sent = JSON.stringify(adapter.requests[0]!.messages) expect(sent).toContain('extra ctx') }) @@ -155,7 +155,7 @@ describe('agent/prompt-submit', () => { }], }, }) - expect(log.some(event => event.type === 'context/message')).toBe(false) + expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) expect(adapter.requests[0]?.messages.at(-1)).toEqual({ role: 'user', content: [ @@ -215,7 +215,6 @@ describe('agent/prompt-submit', () => { expect(log.some(e => e.type === 'turn/start')).toBe(true) 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 === 'context/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') @@ -340,8 +339,8 @@ describe('agent/session-start', () => { // the injected context reached the model on the first (only) request expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') // and is recorded with the plugin source, never mislabeled as a user prompt - const ctxMsg = events(agent).find(e => e.type === 'context/message') - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) + const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) }) it('a throwing session-start listener does not abort agent construction', async () => { @@ -624,23 +623,22 @@ describe('tool additionalContexts buffering across a step', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // Event order in the log: both tool/results, THEN both context/messages — + // Event order in the log: both tool/results, THEN both injected contexts — // never interleaved (which would break tool-call/result adjacency). - const types = events(agent).map(e => e.type) - const firstResult = types.indexOf('tool/result') - const lastResult = types.lastIndexOf('tool/result') - const firstCtx = types.indexOf('context/message') + const injected = events(agent).filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + const seqs = events(agent) + const firstResult = seqs.findIndex(e => e.type === 'tool/result') + const lastResult = seqs.map(e => e.type).lastIndexOf('tool/result') + const firstCtx = seqs.findIndex(e => e === injected[0]) expect(firstResult).toBeGreaterThanOrEqual(0) expect(lastResult).toBeGreaterThan(firstResult) // two results expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results // both contexts present - const ctxTexts = events(agent) - .filter(e => e.type === 'context/message') - .flatMap(e => (e.type === 'context/message' ? e.data.content : [])) + const ctxTexts = injected + .flatMap(e => (e.type === 'user/message' ? e.data.content : [])) .map(b => (b.type === 'text' ? b.text : '')) expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2']) - const contextEvents = events(agent).filter(e => e.type === 'context/message') - expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }]) + expect(injected.map(e => e.type === 'user/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }]) }) it('appends multiple contexts deferred by one composite tool after its outer result', async () => { @@ -661,14 +659,14 @@ describe('tool additionalContexts buffering across a step', () => { const log = events(agent) const resultIndex = log.findIndex(event => event.type === 'tool/result') - const contextEvents = log.filter(event => event.type === 'context/message') + const contextEvents = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(resultIndex).toBeGreaterThanOrEqual(0) expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex) - expect(contextEvents.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + expect(contextEvents.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'a' }, { kind: 'plugin', plugin: 'b' }, ]) - expect(contextEvents.map(event => event.type === 'context/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }]) + expect(contextEvents.map(event => event.type === 'user/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }]) }) }) @@ -750,13 +748,13 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se const log = events(agent) // session-start preamble injected - expect(log.some(e => e.type === 'context/message' + expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin' && e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true) - // prompt allowed → user/message recorded - expect(log.some(e => e.type === 'user/message')).toBe(true) + // prompt allowed → user-sourced user/message recorded + expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'user')).toBe(true) // tool ran (echo allowed) and post-execute attached "audited" context expect(log.some(e => e.type === 'tool/result' && !e.data.isError)).toBe(true) - expect(log.some(e => e.type === 'context/message' + expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin' && e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true) // NO hook/* events — a native plugin needs none expect(log.some(e => e.type.startsWith('hook/'))).toBe(false) diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts index aa8bd5d6d5..cb0dcd2384 100644 --- a/packages/core/agent-loop/tests/invariant.spec.ts +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -42,7 +42,7 @@ describe('request-reconstruction invariant', () => { it('uses the step boundary rather than content appended afterward', async () => { const { ctx, session, boundary } = await requestSetup() - session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' }) + session.append('user/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' }) const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) expect(() => { dispatch(ctx, options) }).not.toThrow() }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 5eaaa2ea5a..3fbb1d8958 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -380,7 +380,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } }) - // The idle inject records a self-contained turn (turn/start → context/message + // The idle inject records a self-contained turn (turn/start → user/message // → turn/end) so the event stays turn-enclosed, but does NOT run the model. await new Promise(r => setTimeout(r, 20)) expect(agent.status).toBe('idle') @@ -416,8 +416,8 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const contextEvent = agent.session.events.find(event => event.type === 'context/message') - expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ meta }) + const contextEvent = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') + expect(contextEvent?.type === 'user/message' && contextEvent.data).toMatchObject({ meta }) const requestText = JSON.stringify(adapter.requests[0]!.messages) expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md') expect(requestText).not.toContain(' { }) first.text = 'mutated after inject' agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } }) - visibleDuringTool = agent.session.events.some(e => e.type === 'context/message') + visibleDuringTool = agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin') return [{ type: 'text', text: 'ok' }] }, })) @@ -462,13 +462,13 @@ describe('agent loop', () => { const ts0 = turnStarts[0]! expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message') const result = agent.session.events.find(e => e.type === 'tool/result')! - const contexts = agent.session.events.filter(e => e.type === 'context/message') + const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(contexts).toHaveLength(2) expect(result.seq).toBeLessThan(contexts[0]!.seq) - expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({ + expect(contexts[0]?.type === 'user/message' && contexts[0].data).toMatchObject({ meta, }) - expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : [])) + expect(contexts.flatMap(event => event.type === 'user/message' ? event.data.content : [])) .toEqual([ { type: 'text', text: 'mid-turn notice' }, { type: 'text', text: 'second notice' }, @@ -512,7 +512,7 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false) + expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) }) it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => { @@ -621,7 +621,7 @@ describe('agent loop', () => { ctx.on('agent/pre-step', (subject) => { if (subject === agent && !injected) { injected = true - subject.session.append('context/message', { + subject.session.append('user/message', { content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }], source: { kind: 'plugin', plugin: 'test' }, }, { surfaceOp: 'append' }) @@ -639,7 +639,7 @@ describe('agent loop', () => { // And the injected event sits BEFORE the first step/start in the log — // the seam fired outside the step. const events = agent.session.events - const injectedSeq = events.find(e => e.type === 'context/message')!.seq + const injectedSeq = events.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')!.seq const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq expect(injectedSeq).toBeLessThan(firstStepStartSeq) }) @@ -1017,13 +1017,13 @@ describe('agent loop', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message') }) - it('keeps a reentrant agent/queued send as the next independent turn', async () => { + it('keeps a reentrant agent/inbox/enqueue send as the next independent turn', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let nested = false - ctx.on('agent/queued', (subject) => { + ctx.on('agent/inbox/enqueue', (subject) => { if (subject !== agent || nested) return nested = true send(agent, 'queued listener message') diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index aad90a7dde..405808ffd3 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -118,7 +118,7 @@ describe('request stability across the loop', () => { preStep() const session = agent.session const nodes = session.surface.nodes - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: '[summary of turn 1]' }], source: { kind: 'plugin', plugin: 'test-compact' }, }, { @@ -180,7 +180,7 @@ describe('request stability across the loop', () => { const first = adapter.requests[0]! // The inject landed in the log after the boundary: not in THIS request… expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false) - expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true) + expect(agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true) send(agent, 'second') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index 1e6bc14548..cb27b6b2f9 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -154,12 +154,15 @@ describe('agent post-step and request-error lifecycle', () => { const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' }) const order: string[] = [] ctx.on('session/event', (_session, event) => { + // Injected context is a plugin-sourced user/message; the direct human + // prompt (user source) stays untracked as before. + const isInjected = event.type === 'user/message' && event.data.source.kind !== 'user' if ( event.type === 'assistant/message' || event.type === 'tool/call' - || event.type === 'tool/result' || event.type === 'context/message' + || event.type === 'tool/result' || isInjected || event.type === 'steering/message' || event.type === 'step/end' ) { - if (!('step' in event.data) || event.data.step === 1) order.push(event.type) + if (!('step' in event.data) || event.data.step === 1) order.push(isInjected ? 'context/message' : event.type) } }) ctx.on('agent/post-step', (subject, turn, step, signal) => { @@ -271,7 +274,7 @@ describe('agent post-step and request-error lifecycle', () => { expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE }) expect(facts.code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE) attempts.push(history.length) - subject.session.append('context/message', { + subject.session.append('user/message', { content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }], source: { kind: 'plugin', plugin: 'test-recovery' }, }, { surfaceOp: 'append' }) @@ -288,7 +291,7 @@ describe('agent post-step and request-error lifecycle', () => { const ends = agent.session.events.filter(event => event.type === 'step/end') expect(starts.map(event => event.data.step)).toEqual([1, 2]) expect(ends.map(event => event.data.step)).toEqual([1, 2]) - const recovery = agent.session.events.find(event => event.type === 'context/message')! + const recovery = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')! expect(ends[0]!.seq).toBeLessThan(recovery.seq) expect(recovery.seq).toBeLessThan(starts[1]!.seq) }) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 35985c9185..9b95c825b0 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -403,11 +403,11 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = await waitForIdle(ctx, agent) const log = events(agent) - const contextTexts = log.filter(e => e.type === 'context/message') - .map(e => (e.data.content[0] as { text: string }).text) + const contextTexts = log.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + .map(e => ((e.data as { content: { text: string }[] }).content[0]!).text) expect(contextTexts).toEqual(['ctx-c1', 'ctx-c2']) const lastResult = log.findLastIndex(e => e.type === 'tool/result') - const firstContext = log.findIndex(e => e.type === 'context/message') + const firstContext = log.findIndex(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(lastResult).toBeLessThan(firstContext) }) @@ -544,10 +544,11 @@ describe('tool-call scheduler: abort handling', () => { expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }), expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }), ]) - const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message') + const settled = events(agent).filter(e => e.type === 'tool/result' + || (e.type === 'user/message' && e.data.source.kind === 'plugin')) expect(settled.map(e => e.type)) - .toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'context/message', 'context/message']) - expect(settled.filter(e => e.type === 'context/message') + .toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'user/message', 'user/message']) + expect(settled.filter(e => e.type === 'user/message') .map(e => (e.data.content[0] as { text: string }).text)) .toEqual(['ctx-c1', 'ctx-c2']) }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index b040d8cbdc..06a7e618f9 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -48,7 +48,7 @@ The lifecycle edges have two important local caveats. `agent/created` runs after Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. -`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent `context/message`; `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata. +`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent injected `user/message` (plugin/goal source); `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata. Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). @@ -56,10 +56,11 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` 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. After admission, separate contexts become `context/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. -- `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to `send()`. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. -- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). -- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; observers may synchronize state but cannot veto cancellation. The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. +- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/inbox/enqueue` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. +- `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. +- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. +- `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `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.session`, `agent.status`, `agent.options`, `agent.id` @@ -107,6 +108,6 @@ Prefix-stable while an agent's scoped registrations are unchanged. Setup or relo - **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work. - **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam. - **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead. -- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). +- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). - **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable. - **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`). diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts index 1902f3e746..a5a7725707 100644 --- a/packages/core/agent/src/invariant.ts +++ b/packages/core/agent/src/invariant.ts @@ -24,6 +24,27 @@ const install: InvariantInstaller = (ctx, fail) => { } lastStatus.set(agent, status) }, { global: true }) + + // Inbox FIFO conservation: an item leaves the inbox (dequeue) or is dropped + // (discard) only after it entered (enqueue), so the live outstanding count + // per agent can never go negative. Injection bypasses the FIFOs entirely and + // never appears on these events. + const outstanding = new WeakMap() + ctx.on('agent/inbox/enqueue', (agent) => { + outstanding.set(agent, (outstanding.get(agent) ?? 0) + 1) + }, { global: true }) + ctx.on('agent/inbox/dequeue', (agent) => { + const count = outstanding.get(agent) ?? 0 + if (count <= 0) fail('agent/inbox/dequeue without a matching prior enqueue') + outstanding.set(agent, count - 1) + }, { global: true }) + ctx.on('agent/inbox/discard', (agent, items) => { + const count = outstanding.get(agent) ?? 0 + if (items.length > count) { + fail(`agent/inbox/discard dropped ${items.length} items but only ${count} were outstanding`) + } + outstanding.set(agent, count - items.length) + }, { global: true }) } /** diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index dc78d76ef5..f04c999cb6 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -26,10 +26,33 @@ export interface AgentOptions { } /** - * Message options. An omitted source attests direct human input as `{ kind: 'user' }` - * and may authorize policy consumers, so non-human producers must label their content. + * Which inbox queue a {@link Agent.send} item joins: + * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. + * - `next-step` — the item joins the active turn between steps as steering, + * or, when no turn is active, is promoted per its `wakeup` flag. + */ +export type SendTarget = 'next-turn' | 'next-step' + +/** + * Options for the unified {@link Agent.send} primitive over the + * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} + * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and + * {@link Agent.inject} (`next-step`/no-wakeup). + * + * An omitted source attests direct human input as `{ kind: 'user' }` and may + * authorize policy consumers, so non-human producers must label their content. */ export interface SendOptions { + /** Queue the item joins; defaults to `next-turn`. */ + target?: SendTarget + /** + * Whether this item makes the model run: wake a parked driver (`next-turn`) + * or force a continuation step (`next-step` while running). Defaults to + * `true`. A `false` `next-turn` item queues without waking; a `false` + * `next-step` item attaches durable context without forcing another step + * (the injection preset). + */ + wakeup?: boolean source?: MessageSource /** * Model-facing contexts captured with this inbox item. A queued prompt exposes @@ -37,19 +60,44 @@ export interface SendOptions { * records them directly at its next checkpoint. */ contexts?: HookContext[] + /** Opaque JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue } -/** Options specific to durable synthetic context injection. */ -export interface InjectOptions extends Omit { - /** Opaque JSON state retained in the session event but hidden from the model. */ - meta?: JsonValue +/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ +export type AliasSendOptions = Omit + +/** + * The resolved facts of one inbox FIFO item, carried by the `agent/inbox/*` + * live events. Source defaults are already applied, so these are the exact + * values the item was accepted with. `steering` is true for a `next-step` + * item drained between steps; a `next-turn` item is claimed at a turn boundary. + */ +export interface InboxItemInfo { + content: ContentBlock[] + source: MessageSource + contexts: HookContext[] + /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ + steering: boolean + /** Whether the item is marked to wake the driver or force a continuation. */ + wakeup: boolean +} + +/** Options for {@link Agent.cancel}. */ +export interface CancelOptions { + /** + * Preserve queued and steering inbox items instead of discarding them. The + * active turn is still aborted, but un-started and pending work survives for a + * later turn and no `agent/inbox/discard` fires. + */ + keepInbox?: boolean } /** * An agent's lifecycle state, emitted on every transition as `agent/status`: * `idle` (parked, waiting for queued work), `running` (the driver is draining * work and may be closing or checkpointing a turn), `disposed` (terminal — no - * transition leaves it, and `send`/`steer`/`inject` throw). + * transition leaves it, and `send`/`followup`/`steer`/`inject` throw). */ export type AgentStatus = 'idle' | 'running' | 'disposed' @@ -58,8 +106,8 @@ export interface HookContext { content: ContentBlock[] source: MessageSource /** - * Model placement. Absent or `separate` records an independent - * `context/message`; `prompt-prefix` prepends this context and a stable + * Model placement. Absent or `separate` records an independent injected + * `user/message`; `prompt-prefix` prepends this context and a stable * request delimiter to the same user-role message as its attached prompt. */ placement?: 'separate' | 'prompt-prefix' @@ -109,58 +157,100 @@ export type AgentCancelCause = /** Runtime reason carried by the signal that controls one live turn. */ export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' } -/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ -export interface Agent { +/** + * Public agent handle; its concrete implementation is internal to + * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so + * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer}, + * {@link Agent.inject}) are shared concrete delegates over the single abstract + * {@link Agent.send} primitive; concrete drivers implement `send` once. + */ +export abstract class Agent { /** The single identity shared with {@link session}. */ - readonly id: SessionId - readonly options: AgentOptions - readonly session: Session - readonly status: AgentStatus + abstract readonly id: SessionId + /** The provider route and model this agent's requests use. */ + abstract readonly options: AgentOptions + /** The live session this agent drives; its log is the durable source of truth. */ + abstract readonly session: Session + /** The current lifecycle state, mirrored on every `agent/status` transition. */ + abstract readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - readonly ctx: Context + abstract readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole - * ordinary message in its FIFO-ordered turn; the next claimed item waits for - * that turn's checkpoint. + * The unified delivery primitive over the (`target` × `wakeup`) matrix. + * Detaches, validates, and freezes one lossless-JSON item, then routes it: + * + * - `next-turn` (default) queues an item that becomes the sole ordinary + * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a + * parked driver, while `wakeup:false` queues without waking. + * - `next-step` with `wakeup:true` submits steering into the active turn + * (idle falls back to a woken `next-turn`). + * - `next-step` with `wakeup:false` injects durable model-facing context + * without running the model: an open turn joins at the current log position + * (deferred behind an executing tool batch until it settles), and an idle + * inject records a one-shot turn with its own durability checkpoint. + * * Attached contexts share the same snapshot and ownership boundary. Invalid - * input throws synchronously before notification or enqueue. + * input throws synchronously before any notification, enqueue, or append. + * @param content - the model-facing content blocks to deliver. + * @param options - target queue, wakeup decision, source, contexts, and meta. */ - send(content: ContentBlock[], options?: SendOptions): void + abstract send(content: ContentBlock[], options?: SendOptions): void /** - * Submit steering while the agent is `running`. An open turn records it at - * the next steering checkpoint before a request or continuation decision; - * policy may stop before another step. After turn close and its checkpoint, - * any remainder is queued for a later turn; terminal `agent/turn-stop`, - * cancellation, or disposal may discard it. Uses the same synchronous - * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. - */ - steer(content: ContentBlock[], options?: SendOptions): void - - /** - * Append detached model-facing context without running the model. An open-turn - * injection joins at the current log position unless the current tool batch is - * executing; then it waits FIFO until that batch settles and drains before turn - * close even when interrupted. Idle injection uses a one-shot turn and durability - * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`. - */ - inject(content: ContentBlock[], options?: InjectOptions): void - - /** - * Clear all queued and steering work, including items waiting to start, and - * abort the active turn. An effective call first emits - * `agent/cancel-requested` with the resolved typed cause. The first cause wins - * for the active turn, and `whenIdle()` resolves after cancellation reaches - * quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op - * and does not arm later work. The active turn snapshots and freezes the cause. + * Clear queued and steering work — unless `keepInbox` — and abort the active + * turn. An effective call first emits `agent/cancel-requested` with the + * resolved typed cause. The first cause wins for the active turn, and + * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause + * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm + * later work. The active turn snapshots and freezes the cause. * @param cause - the stable caller intent carried by the current turn signal. + * @param options - cancellation options; `keepInbox` preserves pending work. */ - cancel(cause?: AgentCancelCause): void + abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ - whenIdle(): Promise + abstract whenIdle(): Promise + /** + * Queue an ordinary follow-up turn and wake the driver — the + * `next-turn`/wakeup preset of {@link send}. The item becomes the sole + * ordinary message of its own turn. + * @param content - the prompt content blocks. + * @param options - source and attached contexts. + */ + followup(content: ContentBlock[], options?: AliasSendOptions): void { + this.send(content, { ...options, target: 'next-turn', wakeup: true }) + } + + /** + * Submit steering into the running turn — the `next-step`/wakeup preset of + * {@link send}. An open turn records it at the next steering checkpoint before + * a request or continuation decision; policy may stop before another step. + * After turn close and its checkpoint, any remainder is queued for a later + * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. + * Idle steering falls back to a woken follow-up turn. + * @param content - the steering content blocks. + * @param options - source and attached contexts. + */ + steer(content: ContentBlock[], options?: AliasSendOptions): void { + this.send(content, { ...options, target: 'next-step', wakeup: true }) + } + + /** + * Append detached model-facing context without running the model — the + * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins + * at the current log position unless the current tool batch is executing; + * then it waits FIFO until that batch settles and drains before turn close + * even when interrupted. Idle injection uses a one-shot turn and durability + * checkpoint. Disposal awaits idle checkpoints; flush failures report through + * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and durable model-hidden meta. + */ + inject(content: ContentBlock[], options?: AliasSendOptions): void { + this.send(content, { ...options, target: 'next-step', wakeup: false }) + } } declare module 'cordis' { @@ -196,15 +286,37 @@ declare module 'cordis' { */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** - * Detached, frozen content entered the agent's inbox. Source defaults have - * already been applied, so these are the exact values retained for the log. - * @param agent - the agent whose inbox received the message. - * @param content - the accepted content blocks retained by the inbox. - * @param info - the accepted source, contexts, and whether it entered as steering. + * A detached, frozen item entered the agent's inbox (queued or steering + * FIFO). Source defaults are already applied, so `info` holds the exact + * accepted values. This is the enqueue-time live signal; the durable record + * is the eventual `user/message`/`steering/message`. Injection + * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. + * @param agent - the agent whose inbox received the item. + * @param info - the accepted content, source, contexts, steering, and wakeup facts. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void + 'agent/inbox/enqueue'(this: Scoped, agent: Agent, info: InboxItemInfo): void + /** + * The driver claimed one item out of the inbox: a queued item at a turn + * boundary, or steering drained between steps. Fires after the item leaves + * its FIFO and before it becomes a durable message. + * @param agent - the agent whose inbox item was claimed. + * @param info - the claimed item's accepted content, source, contexts, steering, and wakeup facts. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/dequeue'(this: Scoped, agent: Agent, info: InboxItemInfo): void + /** + * `cancel()` (without `keepInbox`) dropped pending inbox items without + * delivering them. Fires once per effective clearing call with every + * discarded item, after `agent/cancel-requested` and before the abort. + * @param agent - the agent whose inbox was cleared. + * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/discard'(this: Scoped, agent: Agent, items: InboxItemInfo[]): void /** * Effective broad cancellation was requested, before queued/steering work * is cleared or the active turn is aborted. This observe-only notification diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 509ad14a22..d157eeab57 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -3,26 +3,28 @@ import { Context, Service, symbols } from 'cordis' import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { + Agent, agentEvents, agentInterruptReasonOf, } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import type { AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' -function stubAgent(rawId: string): Agent { +function stubAgent(rawId: string, overrides: Partial = {}): Agent { const id = SessionId(rawId) - return { + // Agent is an abstract class, so its alias methods live on the prototype and + // object spread would drop them; build the full literal and merge overrides. + return Object.assign(Object.create(Agent.prototype) as Agent, { id, options: {}, session: new Session(id), status: 'idle', ctx: new Context(), send() {}, - steer() {}, - inject() {}, cancel() {}, whenIdle() { return Promise.resolve() }, - } + ...overrides, + }) } describe('AgentRegistry', () => { @@ -56,7 +58,7 @@ describe('AgentRegistry', () => { it('rejects an agent whose registry and session identities differ', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - const agent = { ...stubAgent('agent-id'), session: new Session(SessionId('session-id')) } + const agent = stubAgent('agent-id', { session: new Session(SessionId('session-id')) }) expect(() => ctx.agents.enter(agent, undefined)) .toThrow('agent id "agent-id" does not match session id "session-id"') diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index 3c0d147b9a..b850e8743a 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -56,3 +56,41 @@ describe('agent status invariants', () => { expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() }) }) + +describe('agent inbox invariants', () => { + const info = (steering: boolean) => ({ content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true }) + + it('accepts a dequeue and a discard covered by prior enqueues', async () => { + const ctx = await setup() + const agent = mockAgent('i1') + const at = scopeTarget(agent, agent) + expect(() => { + ctx.emit(at, 'agent/inbox/enqueue', agent, info(false)) + ctx.emit(at, 'agent/inbox/enqueue', agent, info(true)) + ctx.emit(at, 'agent/inbox/dequeue', agent, info(false)) + ctx.emit(at, 'agent/inbox/discard', agent, [info(true)]) + }).not.toThrow() + }) + + it('rejects a dequeue with no outstanding item', async () => { + const ctx = await setup() + const agent = mockAgent('i2') + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info(false)) }) + .toThrow(/without a matching prior enqueue/) + }) + + it('rejects a discard larger than the outstanding count', async () => { + const ctx = await setup() + const agent = mockAgent('i3') + const at = scopeTarget(agent, agent) + ctx.emit(at, 'agent/inbox/enqueue', agent, info(false)) + expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(false), info(true)]) }) + .toThrow(/dropped 2 items but only 1 were outstanding/) + }) + + it('accepts an empty discard against a fresh agent', async () => { + const ctx = await setup() + const agent = mockAgent('i4') + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/discard', agent, []) }).not.toThrow() + }) +}) diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index 5988b58145..a12b0a513e 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -12,10 +12,12 @@ const scopedSubjectResolvers: Readonly args[0], 'agent/disposed': args => args[0], 'agent/error': args => args[0], + 'agent/inbox/dequeue': args => args[0], + 'agent/inbox/discard': args => args[0], + 'agent/inbox/enqueue': args => args[0], 'agent/post-step': args => args[0], 'agent/pre-step': args => args[0], 'agent/prompt-submit': args => args[0], - 'agent/queued': args => args[0], 'agent/request': args => args[0], 'agent/request-error': args => args[0], 'agent/session-prefix': args => args[0], diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 2d93bcddc5..bcb3944cbd 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -42,7 +42,9 @@ describe('scoped-dispatch invariants', () => { 'agent/created': [agent], 'agent/disposed': [agent], 'agent/status': [agent, 'idle'], - 'agent/queued': [agent, [], { source: { kind: 'user' }, contexts: [], steering: false }], + 'agent/inbox/enqueue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], + 'agent/inbox/dequeue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], + 'agent/inbox/discard': [agent, []], 'agent/cancel-requested': [agent, { kind: 'user' }], 'agent/session-start': [agent, 'startup'], 'agent/pre-step': [agent, 1, 1, signal], diff --git a/packages/core/session/README.md b/packages/core/session/README.md index c574174a20..958c226767 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -64,7 +64,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/ `request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history. +A `user/message` renders its `content` verbatim as a user-role message whether it is a direct human prompt (`user` source), a synthetic injection (`plugin`/`goal` source), or an admitted goal round — `source` is the only channel that tells them apart. It may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history. ### Session event vocabulary (`types.ts`) @@ -97,7 +97,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) #### What the model sees -The model receives projections of `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. +The model receives projections of `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. #### Token effect diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 9c5831bb18..5e4320f046 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -532,10 +532,10 @@ export class Session { // trace/replay data. switch (event.type) { - // Injected context, ordinary prompts, and mid-turn steering project + // Ordinary prompts, injected context, and mid-turn steering project // identically in user role: the event's model-facing content stays // verbatim. A prompt envelope is model-hidden display metadata; its - // prefix bytes are already present in content. context's `source`/`meta` + // prefix bytes are already present in content. The message's `source`/`meta` // and steering's `turn` are also log-only. Do NOT // re-add per-type framing (e.g. ``/``) here: framing is // caller-owned — a producer bakes it into `content`, as workspace-context @@ -544,7 +544,6 @@ export class Session { // verbatim pass-through. See the deferred design note in // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md case 'user/message': - case 'context/message': case 'steering/message': { return { role: 'user', content: event.data.content } } diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 9095c8388b..fc275129f9 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -15,14 +15,13 @@ const SURFACE_EVENT_TYPES = new Set([ 'user/message', 'assistant/message', 'tool/result', - 'context/message', 'steering/message', ]) /** * Whether an event type can join the model-visible surface. * @param type - event type to test. - * @returns true for one of the five message-producing event types. + * @returns true for one of the four message-producing event types. */ export function isSurfaceEligibleType(type: string): boolean { return SURFACE_EVENT_TYPES.has(type) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 37c174ea12..3983034951 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -84,11 +84,12 @@ export interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } /** * An out-of-band context injection (`agent.inject()`) made while the agent - * was idle. The loop wraps the injected `context/message` in a one-shot turn - * (`turn/start` → `context/message` → `turn/end`) so every event in the log - * stays turn-enclosed — the durability/replay boundary is the turn, and a - * bare event between turns would otherwise be indistinguishable from a crash - * tail on reload. + * was idle. The loop wraps the injected `user/message` (a non-`user` source, + * plugin by default) in a one-shot turn (`turn/start` → `user/message` → + * `turn/end`) so every event in the log stays turn-enclosed — the + * durability/replay boundary is the turn, and a bare event between turns would + * otherwise be indistinguishable from a crash tail on reload. The trigger's + * `source` mirrors that message's producer. */ injection: { kind: 'injection'; source: MessageSource } } @@ -201,7 +202,13 @@ export interface PromptMessageEnvelope { prefixContexts: PromptPrefixContext[] } -/** Shared payload for ordinary and steering prompt messages. */ +/** + * Shared payload for user, injected-context, and steering prompt messages. A + * direct human prompt, a synthetic `agent.inject()` context, and mid-turn + * steering all project into the model transcript as verbatim user-role content; + * they are told apart by `source` (a non-`user` kind marks injected context), + * not by event type. `meta` carries durable model-hidden producer state. + */ export interface PromptMessageData { /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ content: ContentBlock[] @@ -209,6 +216,15 @@ export interface PromptMessageData { source: MessageSource /** Present only when prompt-prefix contexts were baked into `content`. */ envelope?: PromptMessageEnvelope + /** + * Opaque durable JSON state retained on the event but hidden from the model + * projection. It is the intended channel for a future framing directive (a + * producer declares the frame, a dedicated renderer applies it — see the + * deferred note in + * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), + * so the surface keeps projecting `content` verbatim rather than wrapping it. + */ + meta?: JsonValue } /** @@ -236,29 +252,21 @@ export interface SessionEventMap { 'step/start': { turn: number; step: number } /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (the queued message claimed for this turn). */ + /** + * A user-role message on the model-visible surface: a direct human prompt + * (the queued message claimed for this turn), a synthetic `agent.inject()` + * context (file-change notices, subdir AGENTS.md, skill content, cron + * notifications, …), or an admitted goal continuation round. All three + * project their `content` verbatim; `source` (with a non-`user` kind marking + * injected context) is the only channel that tells them apart. An idle + * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + */ 'user/message': PromptMessageData /** * Durable record of a prompt veto and its reason. It is log-only: the blocked * prompt never enters the model-visible surface, and its turn runs zero steps. */ '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 - * as a synthetic user-role message carrying `content` verbatim — NOT a - * user prompt. `meta` is durable JSON state omitted from the model - * projection; it is also the intended channel for any future framing - * directive (a producer declares the frame, a dedicated renderer applies it — - * see the deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ - 'context/message': { - content: ContentBlock[] - source: MessageSource - meta?: JsonValue - } /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** @@ -321,7 +329,6 @@ export type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'context/message' | 'steering/message' /** @@ -339,7 +346,7 @@ export type SurfaceEvent = SessionEvent & { surfaceOp: Surface * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * - `'append'`: added to the tail — normal path for user/assistant/tool/steering * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -374,7 +381,7 @@ export interface SurfaceIntent { * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 96d1f7048c..c2ff24936b 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -38,7 +38,7 @@ describe('derived-message cache', () => { expect(beforeReplace).toHaveLength(2) const nodes = session.surface.nodes - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' }, }, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index d880153dd3..132a250a97 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -62,7 +62,7 @@ describe('Session', () => { turn: 1, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } }, }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'before' }], source: { kind: 'plugin', plugin: 'before' }, }, { surfaceOp: 'append' }) @@ -82,7 +82,7 @@ describe('Session', () => { turn: 3, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } }, }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'after' }], source: { kind: 'plugin', plugin: 'after' }, }, { surfaceOp: 'append' }) @@ -117,9 +117,9 @@ describe('Session', () => { .toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format') }) - it('renders context and steering messages as plain user content', () => { + it('renders injected-context and steering messages as plain user content', () => { const session = new Session(SessionId('s2')) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' }, }, { surfaceOp: 'append' }) @@ -172,7 +172,7 @@ describe('Session', () => { version: 1, changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'abc123' }], } - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'Additional instructions from: pkg/AGENTS.md' }], source: { kind: 'plugin', plugin: 'workspace-context' }, meta, @@ -183,7 +183,7 @@ describe('Session', () => { content: [{ type: 'text', text: 'Additional instructions from: pkg/AGENTS.md' }], }]) const event = session.events[0] - expect(event?.type === 'context/message' && event.data.meta).toEqual(meta) + expect(event?.type === 'user/message' && event.data.meta).toEqual(meta) }) it('replays identically from a seeded event log', () => { diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index b7cbe11b11..cfb84f4e18 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -444,9 +444,9 @@ describe('deriveMessages with surface', () => { expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' }) }) - it('context/message and steering/message appear on surface', () => { + it('injected-context and steering/message appear on surface', () => { const s = new Session(SessionId('ctx')) - s.append('context/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' }) + s.append('user/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' }) s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const messages = s.deriveMessages() expect(messages).toHaveLength(2) @@ -524,7 +524,6 @@ describe('surface type guards', () => { expect(isSurfaceEligibleType('user/message')).toBe(true) expect(isSurfaceEligibleType('assistant/message')).toBe(true) expect(isSurfaceEligibleType('tool/result')).toBe(true) - expect(isSurfaceEligibleType('context/message')).toBe(true) expect(isSurfaceEligibleType('steering/message')).toBe(true) expect(isSurfaceEligibleType('turn/start')).toBe(false) expect(isSurfaceEligibleType('assistant/chunk')).toBe(false) @@ -568,7 +567,7 @@ describe('SurfaceManager.replaceGeneration', () => { expect(s.surface.replaceGeneration).toBe(0) const nodes = s.surface.nodes - s.append('context/message', { + s.append('user/message', { content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' }, }, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] }) expect(s.surface.replaceGeneration).toBe(1) diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index e477924f3f..874ef4f91c 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -365,7 +365,7 @@ describe('runOneShot and executeCli', () => { const { ctx, agent } = await harness([textResponse('streamed')]) const other = ctx.sessions.create(SessionId('unrelated')) let injected = false - ctx.on('agent/queued', (subject) => { + ctx.on('agent/inbox/enqueue', (subject) => { if (subject !== agent || injected) return injected = true agent.inject([{ type: 'text', text: 'startup injection' }], { source: { kind: 'plugin', plugin: 'test' } }) @@ -379,7 +379,7 @@ describe('runOneShot and executeCli', () => { expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'message' } } }) expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 2 } }) expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true) - expect(events.some(event => event.type === 'context/message')).toBe(false) + expect(events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false) }) it('emits partial data and a diagnostic for non-completed turns', async () => { @@ -477,7 +477,7 @@ describe('runOneShot and executeCli', () => { const queued = await harness([textResponse('unused')]) const queuedAbort = new AbortController() - queued.ctx.on('agent/queued', (agent) => { + queued.ctx.on('agent/inbox/enqueue', (agent) => { if (agent === queued.agent) queuedAbort.abort('cancel queued') }) await expect(runOneShot(queued.ctx, { task: 'task', signal: queuedAbort.signal })).rejects.toThrow('cancel queued') diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 35994ecb71..6ea767e091 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' @@ -26,11 +26,11 @@ function nextTurn(session: Session): number { } /** Append one idle injection using the public Agent contract's balanced shape. */ -function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { - const source: MessageSource = options?.source ?? { kind: 'user' } +function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void { + const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' } const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content, source, ...options?.meta === undefined ? {} : { meta: options.meta }, @@ -49,6 +49,7 @@ function stubAgent(id: string): { agent: Agent; session: Session } { ctx: new Context(), get status() { return status }, send() {}, + followup() {}, steer() {}, inject(content, options) { appendInjection(session, content, options) }, cancel() { status = 'idle' }, @@ -125,7 +126,7 @@ describe('/goal human command', () => { expect(created.text).toContain('Rounds: 0/256') expect(created.text).toContain('Activation: armed') expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release') - expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end']) + expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end']) const count = test.session.events.length await expect(run(test, ' replacement')).resolves.toEqual({ diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index bcb3fc2a75..a4883a0f7c 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -306,10 +306,10 @@ export function apply(ctx: Context): void { requestDrive(state) } }) - ctx.on('agent/queued', (agent, content, info) => { + ctx.on('agent/inbox/enqueue', (agent, info) => { const state = stateFor(agent) const attempt = state.attempt - if (attempt !== undefined && sameQueued(content, info.source, attempt)) return + if (attempt !== undefined && sameQueued(info.content, info.source, attempt)) return state.competingQueued = true if (attempt?.phase === 'queued') attempt.stale = true }) diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index f204893578..e323180e88 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -207,7 +207,9 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(2) const rounds: number[] = [] for (const event of test.agent.session.events) { - if (event.type === 'user/message' && event.data.source.kind === 'goal') { + // Round zero is a durable goal state change; positive rounds are the + // admitted continuation prompts this test counts. + if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0) { rounds.push(event.data.source.round) } } @@ -287,7 +289,7 @@ describe('same-session goal driving', () => { it('pauses and drops a reserved round when cancellation lands before admission', async () => { const test = await harness([]) - const cancel = test.ctx.on('agent/queued', (agent, _content, info) => { + const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent === test.agent && info.source.kind === 'goal') { cancel() agent.cancel({ kind: 'user' }) @@ -299,8 +301,10 @@ describe('same-session goal driving', () => { expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' }) expect(test.adapter.requests).toHaveLength(0) + // No admitted continuation round (positive round); goal state changes + // (round zero) are expected in the log. expect(test.agent.session.events.some(event => event.type === 'user/message' - && event.data.source.kind === 'goal')).toBe(false) + && event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false) }) it('pauses an admitted round when cancellation aborts an active step', async () => { @@ -334,7 +338,7 @@ describe('same-session goal driving', () => { const warnings: string[] = [] test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn let inserted = false - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return inserted = true const lastStart = agent.session.events.findLast(event => event.type === 'turn/start') @@ -357,7 +361,7 @@ describe('same-session goal driving', () => { it('makes a reserved round stale when a listener queues human work behind it', async () => { const test = await harness([textResponse('human batch'), textResponse('later goal')]) let inserted = false - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return inserted = true agent.send([{ type: 'text', text: 'human joined the pending batch' }]) @@ -375,7 +379,7 @@ describe('same-session goal driving', () => { it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => { const test = await harness([textResponse('new revision')]) let edited = false - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || edited) return edited = true const current = test.ctx.goals.get(agent) @@ -391,7 +395,7 @@ describe('same-session goal driving', () => { expect(blocked?.type === 'prompt/blocked' ? blocked.data.reason : undefined) .toBe('stale goal-round reservation') const admitted = test.agent.session.events.find(event => event.type === 'user/message' - && event.data.source.kind === 'goal') + && event.data.source.kind === 'goal' && event.data.source.round > 0) expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal' ? admitted.data.source.revision : undefined).toBe(2) @@ -477,8 +481,14 @@ describe('same-session goal driving', () => { it('blocks the goal when a custom agent rejects the otherwise valid send', async () => { const test = await harness([]) - vi.spyOn(test.agent, 'send').mockImplementationOnce(() => { - throw new Error('queue rejected') + // inject shares send, so reject only the round send (a goal-sourced + // next-turn item), not the goal state-change injection that precedes it. + const realSend = test.agent.send.bind(test.agent) + vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { + if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') { + throw new Error('queue rejected') + } + realSend(content, options) }) test.ctx.goals.create(test.agent, { objective: 'handle queue failure' }) @@ -494,9 +504,13 @@ describe('same-session goal driving', () => { it('preserves a custom agent side effect when send disarms before throwing', async () => { const test = await harness([]) - vi.spyOn(test.agent, 'send').mockImplementationOnce(() => { - test.ctx.goals.disarm(test.agent) - throw new Error('queue rejected after disarm') + const realSend = test.agent.send.bind(test.agent) + vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { + if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') { + test.ctx.goals.disarm(test.agent) + throw new Error('queue rejected after disarm') + } + realSend(content, options) }) test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' }) @@ -554,7 +568,7 @@ describe('same-session goal driving', () => { it('fails a pre-admission read closed even when the first disarm attempt throws', async () => { const test = await harness([textResponse('retry after containment')]) let armed = true - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { @@ -635,7 +649,7 @@ describe('same-session goal driving', () => { it('falls back to disarming when a cancelled reservation cannot be paused', async () => { const test = await harness([]) - const cancel = test.ctx.on('agent/queued', (agent, _content, info) => { + const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal') return cancel() vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => { @@ -689,7 +703,7 @@ describe('same-session goal driving', () => { it('cancels an accepted queued round and awaits its driver task during teardown', async () => { const test = await harness([]) let unloading: Promise | undefined - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) { unloading = Promise.resolve(test.driver.dispose()) } diff --git a/packages/goal/goal-session/tests/invariant.spec.ts b/packages/goal/goal-session/tests/invariant.spec.ts index 19200747e8..0427a41333 100644 --- a/packages/goal/goal-session/tests/invariant.spec.ts +++ b/packages/goal/goal-session/tests/invariant.spec.ts @@ -40,7 +40,7 @@ function view(roundsStarted: number): GoalView { function appendChange(session: Session): void { session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: changeSource, meta: change as never, @@ -126,7 +126,7 @@ describe('goal-session prompt invariants', () => { it('attributes an invalid durable prefix during late loading', async () => { const { ctx, session } = await mount(true) session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'counterfeit goal state' }], source: changeSource, meta: change as never, diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index fe88ebcdba..2ff756249f 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -17,7 +17,7 @@ import type { GoalSnapshotChangeMeta, } from './types.ts' -type ContextMessageEvent = Extract +type UserMessageEvent = Extract const SNAPSHOT_OPERATIONS: ReadonlySet> = new Set([ 'create', @@ -310,17 +310,18 @@ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): v } /** - * Decode and verify one model-visible goal context event without folding it. - * @param event - context event whose metadata and rendered content must agree. - * @returns validated change or `undefined` for an unrelated context event. + * Decode and verify one model-visible goal state change without folding it. A + * goal state change is a round-zero goal-sourced `user/message` carrying + * `goal/change` metadata; any other user message returns `undefined`. Goal + * metadata on a non-goal source, or a mismatched attribution or rendered body, + * fails replay loudly. + * @param event - user message whose metadata and rendered content must agree. + * @returns validated change, or `undefined` when the message is not a goal state change. */ -export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | undefined { +export function decodeGoalEvent(event: UserMessageEvent): GoalChangeMeta | undefined { const change = decodeGoalChange(event.data.meta) + if (change === undefined) return undefined const source = goalSource(event.data.source) - if (change === undefined) { - if (source !== undefined) throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`) - return undefined - } const ref = goalChangeRef(change) if (source === undefined || source.goalId !== ref.id || source.revision !== ref.revision || source.round !== 0) { throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`) @@ -338,23 +339,27 @@ export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | un * @returns decoded change for pending-overlay reconciliation. */ export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined { - if (event.type === 'context/message') { - const change = decodeGoalEvent(event) - if (change === undefined) return undefined - applyGoalChange(state, change) - return change - } if (event.type === 'user/message') { - const source = goalSource(event.data.source) - if (source !== undefined) { - const current = state.goal - if (current === undefined || current.phase !== 'active' || source.goalId !== current.id - || source.revision !== current.revision || source.round !== state.roundsStarted + 1 - || source.round > current.maxGoalRounds) { - throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`) - } - state.roundsStarted = source.round + // A goal state change carries `goal/change` metadata (round zero). + const change = decodeGoalEvent(event) + if (change !== undefined) { + applyGoalChange(state, change) + return change } + const source = goalSource(event.data.source) + if (source === undefined) return undefined + // A goal-sourced message without change metadata must be a positive-round + // admitted continuation prompt; round zero owes durable change metadata. + if (source.round === 0) { + throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`) + } + const current = state.goal + if (current === undefined || current.phase !== 'active' || source.goalId !== current.id + || source.revision !== current.revision || source.round !== state.roundsStarted + 1 + || source.round > current.maxGoalRounds) { + throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`) + } + state.roundsStarted = source.round } return undefined } diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 7391c69e93..e4700452fa 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -370,7 +370,9 @@ export class GoalService extends Service { /** Incrementally observe durable events without losing deferred mutations. */ private sync(session: Session, cache: GoalCache): void { for (const event of session.events.slice(cache.observedSeq)) { - if (event.type === 'context/message') { + // A goal state change is a round-zero goal-sourced user message; a + // positive round is a continuation prompt handled by applyGoalEvent. + if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round === 0) { const change = decodeGoalEvent(event) if (change !== undefined) { const pending = cache.pending[0] diff --git a/packages/goal/goal/src/runtime.ts b/packages/goal/goal/src/runtime.ts index 49184faa8c..5a97cceae0 100644 --- a/packages/goal/goal/src/runtime.ts +++ b/packages/goal/goal/src/runtime.ts @@ -3,7 +3,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts' -/** Version of the goal change metadata embedded in `context/message`. */ +/** Version of the goal change metadata embedded in a round-zero `user/message`. */ export const GOAL_CHANGE_VERSION = 1 /** diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index 2c6798718d..7da3c525d7 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -89,7 +89,7 @@ export interface GoalClearChangeMeta { readonly clearedAt: number } -/** Durable metadata union carried by a goal-owned `context/message`. */ +/** Durable metadata union carried by a goal-owned round-zero `user/message`. */ export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta /** Message attribution for durable goal state and continuation rounds. */ diff --git a/packages/goal/goal/tests/goal.e2e.ts b/packages/goal/goal/tests/goal.e2e.ts index 0c582645bc..357bebe227 100644 --- a/packages/goal/goal/tests/goal.e2e.ts +++ b/packages/goal/goal/tests/goal.e2e.ts @@ -50,11 +50,11 @@ describe('goal domain through a real cordis.yml and headless process', () => { expect(result['result']).toContain('CLI tool round trip complete') expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1) - const contexts = events.filter(event => event.type === 'context/message' + const contexts = events.filter(event => event.type === 'user/message' && event.data.source.kind === 'goal') expect(contexts).toHaveLength(1) const context = contexts[0] - if (context?.type !== 'context/message') throw new Error('expected goal context event') + if (context?.type !== 'user/message') throw new Error('expected goal context event') const change = decodeGoalChange(context.data.meta) if (change === undefined) throw new Error('expected durable goal change') expect(change).toMatchObject({ diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index ad2011fc62..18f8d5dfe2 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import GoalService, { @@ -15,7 +15,7 @@ import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek- interface DeferredInjection { content: ContentBlock[] - options: InjectOptions | undefined + options: AliasSendOptions | undefined } interface StubAgent { @@ -33,8 +33,8 @@ function nextTurn(session: Session): number { } /** Mirror the public Agent.inject idle/open-turn contract for domain tests. */ -function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { - const source: MessageSource = options?.source ?? { kind: 'user' } +function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void { + const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' } const context = { content, source, @@ -43,12 +43,12 @@ function appendInjection(session: Session, content: ContentBlock[], options?: In const last = session.events.at(-1) const open = last !== undefined && last.type !== 'turn/end' if (open) { - session.append('context/message', context, { surfaceOp: 'append' }) + session.append('user/message', context, { surfaceOp: 'append' }) return } const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', context, { surfaceOp: 'append' }) + session.append('user/message', context, { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } @@ -65,6 +65,7 @@ function stubAgentForSession(session: Session): StubAgent { ctx: new Context(), get status() { return status }, send() {}, + followup() {}, steer() {}, inject(content, options) { if (shouldDefer) deferred.push({ content, options }) @@ -131,10 +132,10 @@ describe('GoalService creation and replay', () => { }) expect(goal.id).toMatch(/^goal-/) expect(seen).toEqual(['create']) - expect(session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end']) + expect(session.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end']) const context = session.events[1] - expect(context?.type).toBe('context/message') - if (context?.type !== 'context/message') throw new Error('expected goal context') + expect(context?.type).toBe('user/message') + if (context?.type !== 'user/message') throw new Error('expected goal context') expect(context.data.source).toEqual({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 }) const change = decodeGoalChange(context.data.meta) if (change === undefined) throw new Error('expected decoded goal change') @@ -266,7 +267,9 @@ describe('GoalService creation and replay', () => { it('requires the exact live registry instance for reads and mutations', async () => { const { ctx, agent } = await harness() - const impostor = { ...agent, session: new Session(agent.id) } + // A same-id agent backed by a different session object — the live-instance + // check must reject it even though the ids match. + const impostor = stubAgentForSession(new Session(agent.id)).agent expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' })) expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE', @@ -407,8 +410,8 @@ describe('GoalService mutations', () => { vi.setSystemTime(80) ctx.goals.clear(agent, goal) const clear = session.events - .filter(event => event.type === 'context/message') - .map(event => decodeGoalChange(event.data.meta)) + .filter(event => event.type === 'user/message' && event.data.source.kind === 'goal') + .map(event => event.type === 'user/message' ? decodeGoalChange(event.data.meta) : undefined) .at(-1) expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 }) expect(() => foldGoal(session.events)).not.toThrow() @@ -454,7 +457,7 @@ describe('GoalService mutations', () => { ctx.agents.register(stub.agent) let observed: ReturnType ctx.on('session/event', (session, event) => { - if (session === stub.session && event.type === 'context/message') observed = ctx.goals.get(stub.agent) + if (session === stub.session && event.type === 'user/message' && event.data.source.kind === 'goal') observed = ctx.goals.get(stub.agent) }) const created = ctx.goals.create(stub.agent, { objective: 'publish once' }) @@ -517,7 +520,7 @@ describe('GoalService mutations', () => { const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 } as const const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source, meta: change as never, }, { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) @@ -594,7 +597,7 @@ describe('goal replay validation', () => { } const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content: overrides.content ?? renderGoalChange(change), source, meta: change as never, @@ -791,7 +794,7 @@ describe('goal replay validation', () => { const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'missing' }], source, }, { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) @@ -853,7 +856,7 @@ describe('goal replay validation', () => { const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 } as const const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(clear), source, meta: clear as never, }, { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) diff --git a/packages/goal/goal/tests/invariant.spec.ts b/packages/goal/goal/tests/invariant.spec.ts index 85f0b839d1..996743ceca 100644 --- a/packages/goal/goal/tests/invariant.spec.ts +++ b/packages/goal/goal/tests/invariant.spec.ts @@ -45,7 +45,7 @@ describe('goal stream invariants', () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('goal-invariant-valid')) session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: changeSource, meta: change as never, @@ -71,7 +71,7 @@ describe('goal stream invariants', () => { const session = ctx.sessions.create(SessionId('goal-invariant-invalid')) session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) expect(() => { - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'counterfeit' }], source: changeSource, meta: change as never, @@ -82,7 +82,7 @@ describe('goal stream invariants', () => { })) expect(session.seq).toBe(1) expect(() => { - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: changeSource, meta: change as never, @@ -95,7 +95,7 @@ describe('goal stream invariants', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('goal-invariant-late-load')) session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: changeSource, meta: change as never, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index faaf9d1c76..171fe1c0ef 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { CallId } from '@deepseek-ai/dsh-llm' @@ -32,10 +32,11 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { get status() { return status }, ctx: new Context(), send() {}, + followup() {}, steer() {}, - inject(content: ContentBlock[], options?: InjectOptions) { - const source = options?.source ?? { kind: 'user' } - session.append('context/message', { + inject(content: ContentBlock[], options?: AliasSendOptions) { + const source = options?.source ?? { kind: 'plugin', plugin: '' } + session.append('user/message', { content, source, ...options?.meta === undefined ? {} : { meta: options.meta }, @@ -225,7 +226,9 @@ describe('goal tool execution authority', () => { it('rejects stale agent objects and agents outside running status through the executor', async () => { const { ctx, root } = await harness() openTurn(root, { kind: 'user' }) - const stale = { ...root.agent } + // A distinct agent object over root's exact session: same id, not the live + // registered instance, so the executor must reject it. + const stale = stubAgent('goal-tool-stale', root.agent.session).agent const staleResult = await execute(ctx, 'get_goal', {}, stale, stale) expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index b3c90021a5..5646641424 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -35,10 +35,10 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } -/** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */ +/** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */ function reminders(agent: Agent): { text: string; source: unknown }[] { return [...agent.session.events] - .filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message') + .filter((e): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user') .map(e => ({ text: e.data.content.map(block => block.type === 'text' ? block.text : '').join('|'), source: e.data.source, diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 953b4befd0..e5858e8e54 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -125,8 +125,8 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { // The injected context reached the model and is recorded with the plugin source. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('remember: be brief') - const ctxMsg = events(agent).find(e => e.type === 'context/message') - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude' }) + const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind !== 'user') + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude' }) }) }) @@ -216,10 +216,10 @@ describe('hooks-claude bridge — PostToolUse', () => { const log = events(agent) const resultIdx = log.findIndex(e => e.type === 'tool/result') - const ctxIdx = log.findIndex(e => e.type === 'context/message') + const ctxIdx = log.findIndex(e => e.type === 'user/message' && e.data.source.kind !== 'user') expect(ctxIdx).toBeGreaterThan(resultIdx) // context appended AFTER the tool result const ctxMsg = log[ctxIdx] - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true) + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true) }) it('a PreToolUse permissionDecision:ask degrades to ask (the tool is gated, not run)', async () => { @@ -262,7 +262,7 @@ describe('hooks-claude bridge — SessionStart', () => { // session-start fires async (detached .then → agent.inject); wait for the // injected context/message to actually land before sending, rather than a // fixed sleep that flakes under load. - await waitFor(() => events(agent).some(e => e.type === 'context/message' + await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs')))) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index e303c589be..63b127e794 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -138,9 +138,9 @@ export function defineCoverageCases(group: CoverageGroup): void { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) - // The prompt proceeded unchanged; no context/message injected. + // The prompt proceeded unchanged; no injected context. expect(adapter.requests).toHaveLength(1) - expect(events(agent).some(e => e.type === 'context/message')).toBe(false) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) }) it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => { @@ -441,7 +441,7 @@ export function defineCoverageCases(group: CoverageGroup): void { expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) // additionalContext also injected (the block + context arm). - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true) }) it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => { @@ -475,7 +475,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent) - expect(events(handle.agent).some(e => e.type === 'context/message' + expect(events(handle.agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) await handle.dispose() }) @@ -496,7 +496,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // the downstream block won: the model was never called, no user/message was // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected` expect(adapter.requests).toHaveLength(0) - expect(events(agent).some(e => e.type === 'user/message')).toBe(false) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) const turnEnd = events(agent).findLast(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) }) @@ -528,12 +528,12 @@ export function defineCoverageCases(group: CoverageGroup): void { // the original prompt was replaced by the downstream rewrite const userMsg = events(agent).find(e => e.type === 'user/message') expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true) - const contexts = events(agent).filter(event => event.type === 'context/message') - expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'hooks-claude' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) + expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { @@ -551,7 +551,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => { @@ -573,12 +573,12 @@ export function defineCoverageCases(group: CoverageGroup): void { agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) - const contexts = events(agent).filter(event => event.type === 'context/message') - expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'hooks-claude' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) + expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => { @@ -599,7 +599,7 @@ export function defineCoverageCases(group: CoverageGroup): void { expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) // the bridge's context still landed (folded onto the block) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) }) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index d0f0df92f6..4cfdd93e82 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -136,12 +136,12 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro expect(req).toContain('from-bridge') expect(req).toContain('from-downstream') expect(req).toContain('rewritten-prompt') - const contexts = events(agent).filter(event => event.type === 'context/message') - expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'hooks-codex' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) + expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) }) @@ -157,7 +157,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => { @@ -177,12 +177,12 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const contexts = events(agent).filter(event => event.type === 'context/message') - expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'hooks-codex' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) + expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => { @@ -197,7 +197,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) it('SessionStart additionalContext is injected for the first request', async () => { @@ -206,7 +206,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'context/message' + await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx') @@ -233,7 +233,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) }) }) @@ -345,7 +345,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the clean no-output hook has finished agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(events(agent).some(e => e.type === 'context/message')).toBe(false) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) }) it('a throwing SessionStart inject is contained (logged)', async () => { @@ -428,7 +428,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true) }) it('commandOf reads a non-string command arg as an empty command', async () => { @@ -521,7 +521,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the exit-2 hook has finished - expect(events(agent).some(e => e.type === 'context/message' + expect(events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false) }) @@ -545,7 +545,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'context/message' + await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 27d54c5a69..916a39baf1 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -332,7 +332,7 @@ export class PlanModeService extends Service { const text = target ? 'The user switched this session to plan mode.' : 'The user switched this session back to the default mode.' - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'plan-mode' }, }, { surfaceOp: 'append' }) diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index f1c57b5938..1f78ae3511 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -92,7 +92,7 @@ describe('plan mode through the agent loop', () => { const result = findEvent(log, 'tool/result') expect(result.data.isError).toBe(false) expect(foldPlanMode(log)).toBe(true) - expect(log.some(event => event.type === 'context/message')).toBe(false) + expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) }) it('a user flip between turns lands at the boundary: one notice and a changed header with stable tool schemas', async () => { @@ -115,9 +115,9 @@ describe('plan mode through the agent loop', () => { const log = agent.session.events expect(foldPlanMode(log)).toBe(true) - const notices = log.filter(event => event.type === 'context/message') + const notices = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(notices).toHaveLength(1) - expect(findEvent(log, 'context/message').data.content).toEqual([ + expect(notices[0]?.type === 'user/message' && notices[0].data.content).toEqual([ { type: 'text', text: 'The user switched this session to plan mode.' }, ]) // The changed request is logged as a complete snapshot. @@ -163,7 +163,8 @@ describe('plan mode through the agent loop', () => { expect(firstEnd?.seq).toBeLessThan(planMode.seq) expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0) expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section) - expect(findEvent(log, 'context/message').data.content).toEqual([ + const notice = log.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') + expect(notice?.type === 'user/message' && notice.data.content).toEqual([ { type: 'text', text: 'The user switched this session to plan mode.' }, ]) }) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index b5dc9723de..c3c0b4d2ea 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -95,7 +95,7 @@ function header(session: Session): void { function noticeTexts(session: Session): string[] { return session.events - .filter(event => event.type === 'context/message') + .filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') .map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join('')) } diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 1273824033..360b71c3f8 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index b2d6a38255..ad463a0352 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -34,7 +34,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 17b0302ea6..4a690ed520 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -28,6 +28,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { status: 'idle', ctx: scopeFiber.ctx, send() {}, + followup() {}, steer() {}, inject() {}, cancel() {}, diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index 38a5cb4ed6..f71a637928 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 5adcaea441..0439e5f876 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -17,7 +17,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index ee8b1d833f..de31980f48 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -107,7 +107,7 @@ function appendTraceEvents(session: Session): void { { surfaceOp: { op: 'replace', start: 3, end: 3 }, sourceEventSeqs: [3, 2] }, ) session.append( - 'context/message', + 'user/message', { content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }, { surfaceOp: 'append' }, ) @@ -309,14 +309,14 @@ describe('session event tracing', () => { const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } }) live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) live.append( - 'context/message', + 'user/message', { content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } }, { surfaceOp: 'append' }, ) TracePersistence.listFailure = new Error('list unavailable') TracePersistence.loadFailure = new Error('load unavailable') await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 1 })) - .resolves.toMatchObject({ target: { type: 'context/message' } }) + .resolves.toMatchObject({ target: { type: 'user/message' } }) expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1]) TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 1cfb809601..a1401bbb6d 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -68,7 +68,7 @@ describe('startInProcessRun', () => { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } }, }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'late metadata' }], source: { kind: 'plugin', plugin: 'late-metadata' }, }, { surfaceOp: 'append' }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 7ba0551f34..c2dbe00dc9 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -187,10 +187,10 @@ describe('dsh-subagent-spawn', () => { expect(published).toEqual([]) }) - it('a cancel from agent/queued maps a no-turn child log to aborted', async () => { + it('a cancel from agent/inbox/enqueue maps a no-turn child log to aborted', async () => { const { ctx, parent } = await setup([]) const controller = new AbortController() - ctx.on('agent/queued', () => { controller.abort('queued-window') }) + ctx.on('agent/inbox/enqueue', () => { controller.abort('queued-window') }) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) const result = await run.result expect(result).toMatchObject({ stopReason: 'aborted', output: [] }) diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index e4866212b6..2dd0cde142 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -34,7 +34,7 @@ The current executable companions protect these relationships: | Companion | Checks | |---|---| -| `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, scoped subjects, and model-request reconstruction. | +| `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, inbox FIFO conservation, scoped subjects, and model-request reconstruction. | | `dsh-llm`, `dsh-llm-retry`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, durable retry position and bounds, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. | | `dsh-compact`, `dsh-hook-protocol`, `dsh-sandbox-policy` | Durable compaction and hook pairing, compaction metadata, and sandbox-mode vocabulary. | | `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. | diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index 0d3eae8338..b4d57191a2 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -24,6 +24,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { status: 'idle' as const, ctx: scopeFiber.ctx, send() {}, + followup() {}, steer() {}, inject() {}, cancel() {}, diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 7e0b15ff9c..b99b7a4ee2 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1333,8 +1333,8 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { * generic fallback (title = tool name, raw args as input) when no registry is * available (e.g. pure translator tests). * - * Other event types (turn/step boundaries, context/message, …) produce - * no client update. + * Other event types (turn/step boundaries, injected-context user messages, …) + * produce no client update. * @param sessionId - the ACP session id stamped on every emitted notification. * @param event - the harness session event to translate. * @param notify - sink for each produced `session/update` notification; called @@ -1374,6 +1374,9 @@ export function streamSessionEventUpdate( } case 'user/message': { if (!includeUserMessages) return + // Only a direct human prompt replays as a user message; injected context + // (plugin/goal source) is not the user's turn and produces no update. + if (event.data.source.kind !== 'user') return // Replay the user's prompt so a loaded session shows both sides of each // turn. Live prompt turns suppress this path to avoid duplicating what // the client just sent. @@ -1420,7 +1423,7 @@ export function streamSessionEventUpdate( notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } }) return } - // non-error turn/step boundaries, context/message, steering, + // non-error turn/step boundaries, injected-context user messages, steering, // assistant/message — no direct ACP client update. default: return diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index 1e15910ce7..6e24acce24 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -383,7 +383,7 @@ describe('acp bridge', () => { }, }], }) - expect(target.events.some(event => event.type === 'context/message')).toBe(false) + expect(target.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false) const request = JSON.stringify(harness.adapter.requests[0]?.messages) expect(request).toContain('untrusted, read-only snapshot') expect(request).toContain('source background') diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 4ca6775d06..2620c893de 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -285,10 +285,10 @@ describe('acp bridge — turn outcomes', () => { const sessionId = await newSession(harness) const agent = harness.ctx.agents.get(SessionId(sessionId))! // On the queued prompt, synchronously inject a one-shot context turn (idle - // inject writes turn/start{injection} → context/message → turn/end). Fire + // inject writes turn/start{injection} → user/message → turn/end). Fire // once so it lands between install and the prompt turn. let injected = false - harness.ctx.on('agent/queued', (subject) => { + harness.ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent && !injected) { injected = true agent.inject([{ type: 'text', text: 'ctx note' }], { source: { kind: 'plugin', plugin: 'test' } }) diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 40dbc81acd..5105023581 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -241,7 +241,7 @@ describe('HarnessSdkServer', () => { turn: 2, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } }, }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'late metadata' }], source: { kind: 'plugin', plugin: 'late-metadata' }, }, { surfaceOp: 'append' }) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 4a16aa87d0..d473c4ac97 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1487,12 +1487,12 @@ export function createTuiChat( let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined let runningStatus: RunningStatus | undefined - // Steering messages queued during the running turn (`agent/queued`) that the - // loop has not yet drained, shown as a badge on the status line. Each entry is - // the queued message's serialized source: a drain (`steering/message`) removes - // one MATCHING entry, so loop-authored steering — continuation reasons enter - // the inbox without an `agent/queued` event — cannot consume a pending user - // message's slot. Cleared on leaving `running`, which also absorbs a + // Steering messages queued during the running turn (`agent/inbox/enqueue`) + // that the loop has not yet drained, shown as a badge on the status line. Each + // entry is the queued message's serialized source: a drain (`steering/message`) + // removes one MATCHING entry, so loop-authored steering — continuation reasons + // enter the inbox without an `agent/inbox/enqueue` event — cannot consume a + // pending user message's slot. Cleared on leaving `running`, which also absorbs a // cancellation that discards the queue without logging drains; the status // line exists only while running, so idle carries no badge to keep current. const pendingSteering: string[] = [] @@ -1795,6 +1795,29 @@ export function createTuiChat( const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => { switch (event.type) { case 'user/message': { + // Injected context (plugin/goal source) renders as a dim context card, + // not a human bubble; only a direct human prompt is a user message. The + // boolean avoids narrowing `source`, so the label keeps its full union. + const source = event.data.source + if (source.kind !== 'user') { + const references = sessionReferenceCard(event.data.meta) + if (references !== undefined) { + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) + break + } + const text = displayText(contentText(event.data.content).trim()) + if (text) { + // The tui type view lacks plugin-augmented source kinds (e.g. goal), + // so read the display label without narrowing on `kind`. + const labelled = source as { kind: string; plugin?: string } + const label = labelled.plugin ?? labelled.kind + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 1, 0)) + chat.addChild(new Text(palette.muted(text), 1, 0)) + } + break + } const text = displayText(contentText(displayPromptContent(event.data)).trim()) if (text) { chat.addChild(new Spacer(1)) @@ -1819,22 +1842,6 @@ export function createTuiChat( } break } - case 'context/message': { - const references = sessionReferenceCard(event.data.meta) - if (references !== undefined) { - chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) - break - } - const text = displayText(contentText(event.data.content).trim()) - if (text) { - const source = event.data.source.kind === 'plugin' ? event.data.source.plugin : event.data.source.kind - chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Context · ${displayText(source)}`), 1, 0)) - chat.addChild(new Text(palette.muted(text), 1, 0)) - } - break - } case 'prompt/blocked': appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning') break @@ -1919,7 +1926,6 @@ export function createTuiChat( const isSurface = event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'tool/result' - || event.type === 'context/message' || event.type === 'steering/message' if (isSurface && !active.has(event.seq)) continue if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue @@ -2554,7 +2560,7 @@ export function createTuiChat( // A queued steering message reached the model as it drained; drop its // entry from the badge. Matching by source keeps loop-authored steering // (e.g. continuation reasons), which logs here without a matching - // `agent/queued` increment, from consuming a pending user slot. + // `agent/inbox/enqueue` increment, from consuming a pending user slot. const drained = pendingSteering.indexOf(JSON.stringify(event.data.source)) if (drained >= 0) { pendingSteering.splice(drained, 1) @@ -2568,7 +2574,7 @@ export function createTuiChat( renderEvent(event, { addHistory: false, renderChunks: true }) requestRender() }) - const disposeQueued = ctx.on('agent/queued', (subject, _content, info) => { + const disposeQueued = ctx.on('agent/inbox/enqueue', (subject, info) => { if (subject !== agent || !info.steering) return pendingSteering.push(JSON.stringify(info.source)) refreshStatus() diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index c6da283236..c8755e4bb3 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -153,6 +153,10 @@ export async function createTuiTestHarness { type: 'text', text: '\n\n## My request:\n', }) - expect(target.session.events.some(event => event.type === 'context/message')).toBe(false) + expect(target.session.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false) const snapshot = await terminal.snapshot({ includeScrollback: true }) if (REFRESHING) { diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 9a2f3b51e1..1c718a736f 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -451,7 +451,7 @@ describe('TUI terminal-state snapshots', () => { session.append('todo/write', { todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }], }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }], source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` }, }, { surfaceOp: 'append' }) @@ -567,7 +567,7 @@ describe('TUI terminal-state snapshots', () => { await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true }) await renderAfter(harness, () => { - harness.session.append('context/message', { + harness.session.append('user/message', { content: [{ type: 'text', text: 'Compacted summary: the prior command completed and its details were retired from the active surface.' }], source: { kind: 'plugin', plugin: 'compact' }, }, { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 22a50c9ccf..667db4abb3 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -374,8 +374,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - result.session.append('context/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) + result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' }) appendAssistant(result.session, []) result.session.append('step/end', { turn: 1, step: 1 }) @@ -552,16 +552,16 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).not.toContain('queued') const queueSteering = (text: string): void => { - result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text }], { source: { kind: 'user' }, contexts: [], steering: true }) + result.ctx.emit('agent/inbox/enqueue', result.agent, { content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) } const drainSteering = (text: string): void => { result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' }) } // A steering queue for a different agent never touches this status line. - const other = { ...result.agent, id: SessionId('other') } as Agent + const other = { ...result.agent, id: SessionId('other') } as unknown as Agent result.terminal.output = '' - result.ctx.emit('agent/queued', other, [{ type: 'text', text: 'elsewhere' }], { source: { kind: 'user' }, contexts: [], steering: true }) + result.ctx.emit('agent/inbox/enqueue', other, { content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) await tick() expect(result.terminal.output).not.toContain('queued') @@ -574,7 +574,7 @@ describe('pi-tui chat lifecycle and transcript', () => { // A non-steering queue (an idle-style send) leaves the badge untouched. result.terminal.output = '' - result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text: 'sent' }], { source: { kind: 'user' }, contexts: [], steering: false }) + result.ctx.emit('agent/inbox/enqueue', result.agent, { content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }) drainSteering('first') await tick() expect(result.terminal.output).toContain('1 queued') @@ -594,7 +594,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output).toContain('1 queued') - // A loop-authored steering event (plugin source, no matching agent/queued) + // A loop-authored steering event (plugin source, no matching agent/inbox/enqueue) // cannot consume a pending user slot, even when it drains first. result.terminal.output = '' result.session.append('steering/message', { @@ -627,7 +627,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const idle = await setup() // A steering queue arriving while idle has no status line to badge, so the // refresh is a no-op beyond requesting a render. - idle.ctx.emit('agent/queued', idle.agent, [{ type: 'text', text: 'early' }], { source: { kind: 'user' }, contexts: [], steering: true }) + idle.ctx.emit('agent/inbox/enqueue', idle.agent, { content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' }) await tick() expect(idle.terminal.output).not.toContain('Executing tools') @@ -1221,7 +1221,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Referenced sessions · Steering source (steering-source)') expect(result.terminal.output).not.toContain('hidden non-reference prefix') - result.session.append('context/message', { + result.session.append('user/message', { content: [{ type: 'text', text: 'secret full snapshot payload' }], source: { kind: 'plugin', plugin: 'session-reference' }, meta: { @@ -1240,13 +1240,13 @@ describe('pi-tui chat lifecycle and transcript', () => { [{ kind: 'session-reference', references: [{}] }, 'invalid-fields'], ] for (const [meta, text] of invalidCards) { - result.session.append('context/message', { + result.session.append('user/message', { content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'session-reference' }, meta, }, { surfaceOp: 'append' }) } - result.session.append('context/message', { + result.session.append('user/message', { content: [{ type: 'text', text: 'same-label snapshot' }], source: { kind: 'plugin', plugin: 'session-reference' }, meta: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] }, @@ -1658,7 +1658,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const events = await setup() const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session')) - const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } + const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } as unknown as Agent unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] }) agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running') @@ -2021,7 +2021,7 @@ describe('tool cards and surface replay', () => { turn: 1, step: 1, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false, }, { surfaceOp: 'append' }) const start = result.session.surface.nodes[0] as number - result.session.append('context/message', { + result.session.append('user/message', { content: [{ type: 'text', text: 'summary replacement' }], source: { kind: 'plugin', plugin: 'compact' }, }, { @@ -2207,7 +2207,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) @@ -2231,7 +2231,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -2265,14 +2265,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -2302,7 +2302,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -2344,7 +2344,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 40f1a22377..2eee7e0bb0 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -36,6 +36,7 @@ export const LINK_MAP: Record = { ContinuationStop: 'core.md', GenerateOptions: 'core.md', HookContext: 'core.md', + InboxItemInfo: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', LlmFailure: 'llm-streaming.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c93f70cf81..0c6175d61c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -911,7 +911,7 @@ function renderLifecycle(): string { ' participant Persistence', ' participant SDK as UI or SDK listener', ' User->>Agent: send(content)', - ` Agent-->>SDK: ${mermaidCode('agent/queued')}`, + ` Agent-->>SDK: ${mermaidCode('agent/inbox/enqueue')}`, ' Agent->>Driver: queued work wakes driver', ` Driver-->>SDK: ${mermaidCode('agent/status')} running`, ` Driver->>Session: ${mermaidCode('turn/start')}`, @@ -988,7 +988,7 @@ function renderToolPipeline(): string { ` owned["Tool-owned session events
${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`, ` post["${mermaidCode('tools/post-execute')} waterfall
accept, block, replace, add context"]`, ` final["${mermaidCode('tools/result')} synchronous notification
frozen authoritative outcome"]`, - ' context["Active-batch additionalContexts FIFO
context/message after recorded tool results"]', + ' context["Active-batch additionalContexts FIFO
injected user/message after recorded tool results"]', ` toolResult["Session event: ${mermaidCode('tool/result')}
single model-facing outcome"]`, ' allResults["Tool batch settled
recorded tool/result events complete"]', ' presentResult["UI completed card
presentResult(args, result)"]', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index cc85d48ba5..0ddfff5aa6 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -263,7 +263,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-goal', source: 'packages/goal/tool-goal/src/index.ts', requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'], - writes: ['tool/call', 'context/message goal snapshot for mutations', 'tool/result'], + writes: ['tool/call', 'user/message goal snapshot for mutations', 'tool/result'], async mount(ctx) { await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) @@ -336,7 +336,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-tasks', source: 'packages/tasks/tool-tasks/src/index.ts', requires: ['ctx.tools', 'ctx.tasks', 'ctx.systemPrompt'], - writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'], + writes: ['tool/call', 'tool/result', 'user/message via agent.inject() for background completion notices'], async mount(ctx) { await ctx.plugin(TaskService) await ctx.plugin(ToolTasks) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f95f47e580..5014148504 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -66,6 +66,11 @@ "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "SendTarget", + "source": "packages/core/agent/src/types.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "SendOptions", @@ -73,12 +78,22 @@ }, { "doc": "docs/core-data-structures/core.md", - "symbol": "AgentCancelCause", + "symbol": "AliasSendOptions", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", - "symbol": "InjectOptions", + "symbol": "InboxItemInfo", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "CancelOptions", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "AgentCancelCause", "source": "packages/core/agent/src/types.ts" }, { From 95e75ba3e05636741f1a8508a8fa3d0f49f2a70f Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 23 Jul 2026 19:37:21 +0800 Subject: [PATCH 02/37] fix(agent-loop): balance inbox invariant on continuation-reason steer The FIFO-conservation invariant fired on the loop-authored continuation reason path: a continue-with-reason decision entered the steering FIFO without an agent/inbox/enqueue, so its later dequeue/discard had no matching enqueue. Emit the enqueue for that steer too, add a regression test that mounts the invariant over a continue-with-reason turn and a cancel, and hoist the duplicated inboxInfo helper into inbox.ts. Found by fresh-eye review. --- ...nified-send-and-coalesced-user-messages.md | 2 +- packages/core/agent-loop/src/agent.ts | 13 +-- packages/core/agent-loop/src/inbox.ts | 12 ++- packages/core/agent-loop/src/loop.ts | 17 ++-- .../agent-loop/tests/inbox-invariant.spec.ts | 89 +++++++++++++++++++ packages/ui/tui/src/index.ts | 23 ++--- packages/ui/tui/tests/tui.spec.ts | 5 +- 7 files changed, 128 insertions(+), 33 deletions(-) create mode 100644 packages/core/agent-loop/tests/inbox-invariant.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 0dfa875d0b..3bf721f0fd 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -18,7 +18,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj **Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata. -**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO; carries `target`/`wakeup` on `InboxItemInfo`), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items). Injection never touches a FIFO and emits none of these. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. +**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO; carries `target`/`wakeup` on `InboxItemInfo`), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items). Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. **cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped). diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index d118fb3063..670945a8fa 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -9,12 +9,12 @@ import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' import { Agent } from '@deepseek-ai/dsh-agent' -import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, InboxItemInfo, SendOptions } from '@deepseek-ai/dsh-agent' +import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, SendOptions } from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts' -import { Inbox, type InboxMessage } from './inbox.ts' +import { Inbox, inboxInfo, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' /** Sessions already claimed by a concrete driver construction. */ @@ -205,11 +205,6 @@ export class ReactLoopAgent extends Agent { return deepFreeze(accepted) } - /** Build the `agent/inbox/*` payload for one accepted item. */ - private inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo { - return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } - } - /** Detach one context before it can outlive its caller in the active-batch FIFO. */ private acceptContext(context: HookContext): HookContext { const accepted = snapshotJsonValue(context) @@ -240,7 +235,7 @@ export class ReactLoopAgent extends Agent { } else { this.#inbox.enqueue(accepted, wakeup) } - agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', this.inboxInfo(accepted, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', inboxInfo(accepted, steering)) } /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ @@ -351,7 +346,7 @@ export class ReactLoopAgent extends Agent { // Clear work already present before abort observers run. this.#inbox.clear() if (discarded.length > 0) { - const items = discarded.map(({ message, steering }) => this.inboxInfo(message, steering)) + const items = discarded.map(({ message, steering }) => inboxInfo(message, steering)) agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) } } diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index a5ddd87b1f..e6bbbcfb7f 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -7,7 +7,7 @@ */ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { HookContext } from '@deepseek-ai/dsh-agent' +import type { HookContext, InboxItemInfo } from '@deepseek-ai/dsh-agent' /** One message waiting in an agent's inbox. */ export interface InboxMessage { @@ -18,6 +18,16 @@ export interface InboxMessage { wakeup: boolean } +/** + * Build the `agent/inbox/*` event payload for one inbox item. + * @param message - the accepted inbox record. + * @param steering - whether the item is in the steering FIFO (`next-step`). + * @returns the live-event facts for enqueue/dequeue/discard. + */ +export function inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo { + return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } +} + /** * Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO * (drained between steps of a running turn). Purely an in-memory mechanism of diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index c13fa5a74f..11733b17e8 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -10,7 +10,7 @@ import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFai import { isDeepStrictEqual } from 'node:util' import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent' -import type { AgentEventDispatch, ContinuationDecision, HookContext, InboxItemInfo, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' +import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { createTransmissionLog, recordRequestHeader } from './request-log.ts' @@ -19,14 +19,9 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' -import type { Inbox, InboxMessage } from './inbox.ts' +import { inboxInfo, type Inbox, type InboxMessage } from './inbox.ts' import type { TurnCancellation } from './cancellation.ts' -/** Build the `agent/inbox/dequeue` payload for one claimed inbox item. */ -function inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo { - return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } -} - /** Normalize thrown values while preserving an existing error code. */ function toError(error: unknown): RequestError { return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) @@ -543,9 +538,13 @@ async function runTurn( break } - // A continuation reason becomes next-step steering. + // A continuation reason becomes next-step steering. Publish the same + // enqueue event a public steer would, so the inbox ledger stays balanced + // (every FIFO entry has a matching enqueue before its dequeue/discard). if (decision.action === 'continue' && decision.reason) { - handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [], wakeup: true }) + const item: InboxMessage = { content: decision.reason.content, source: decision.reason.source, contexts: [], wakeup: true } + handle.inbox.steer(item) + events.emit('agent/inbox/enqueue', inboxInfo(item, true)) } let shouldContinue = decision.action === 'continue' diff --git a/packages/core/agent-loop/tests/inbox-invariant.spec.ts b/packages/core/agent-loop/tests/inbox-invariant.spec.ts new file mode 100644 index 0000000000..ac84c3d157 --- /dev/null +++ b/packages/core/agent-loop/tests/inbox-invariant.spec.ts @@ -0,0 +1,89 @@ +/** + * Regression: the dsh-agent FIFO-conservation invariant must stay balanced on + * the loop-authored continuation-reason steering path. A continue-with-reason + * decision enters the steering FIFO and later drains (or is discarded by + * cancel); both must be matched by an enqueue event so the invariant's + * outstanding count never goes negative. + * @module dsh-agent-loop/tests/inbox-invariant + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { MockAdapter, textResponse } from './mock-adapter.ts' + +async function harness(adapter: MockAdapter) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(InvariantService) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { dispose(); resolve() } + }) + }) +} + +describe('inbox FIFO-conservation invariant', () => { + it('stays balanced when a continuation reason enters and drains the steering FIFO', async () => { + const adapter = new MockAdapter([textResponse('step 1'), textResponse('step 2')]) + const ctx = await harness(adapter) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + let forced = false + ctx.on('agent/turn-continuation', async (_agent, _turn, _default, _signal, next) => { + if (forced) return next() + forced = true + return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } + }) + + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(2) + // The continuation reason drained as a steering/message on the second step. + expect(agent.session.events.some(e => e.type === 'steering/message')).toBe(true) + // No invariant violation was logged. + expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) + }) + + it('stays balanced when cancel discards a pending continuation reason', async () => { + const adapter = new MockAdapter([textResponse('only step')]) + const ctx = await harness(adapter) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + // Force a continuation reason, then cancel from the same checkpoint so the + // reason sits in the steering FIFO when the inbox is discarded. + ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => { + if (subject !== agent) return next() + queueMicrotask(() => { agent.cancel({ kind: 'user' }) }) + return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } + }) + + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) + }) +}) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index d473c4ac97..d493c26a9c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1487,14 +1487,15 @@ export function createTuiChat( let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined let runningStatus: RunningStatus | undefined - // Steering messages queued during the running turn (`agent/inbox/enqueue`) - // that the loop has not yet drained, shown as a badge on the status line. Each - // entry is the queued message's serialized source: a drain (`steering/message`) - // removes one MATCHING entry, so loop-authored steering — continuation reasons - // enter the inbox without an `agent/inbox/enqueue` event — cannot consume a - // pending user message's slot. Cleared on leaving `running`, which also absorbs a - // cancellation that discards the queue without logging drains; the status - // line exists only while running, so idle carries no badge to keep current. + // Steering messages queued during the running turn (`agent/inbox/enqueue` + // with `info.steering`) that the loop has not yet drained, shown as a badge on + // the status line. Each entry is the queued message's serialized source: a + // drain (`steering/message`) removes one MATCHING entry, so a loop-authored + // continuation reason (which enqueues and drains under its own source) pushes + // and pops its own slot and cannot consume a pending user message's slot. + // Cleared on leaving `running`, which also absorbs a cancellation that + // discards the queue without logging drains; the status line exists only + // while running, so idle carries no badge to keep current. const pendingSteering: string[] = [] let disposed = false let shuttingDown: Promise | undefined @@ -2558,9 +2559,9 @@ export function createTuiChat( advanceTurnPhase(event) if (event.type === 'steering/message') { // A queued steering message reached the model as it drained; drop its - // entry from the badge. Matching by source keeps loop-authored steering - // (e.g. continuation reasons), which logs here without a matching - // `agent/inbox/enqueue` increment, from consuming a pending user slot. + // entry from the badge. Matching by source keeps a loop-authored + // continuation reason popping its own enqueued slot rather than a pending + // user message's slot. const drained = pendingSteering.indexOf(JSON.stringify(event.data.source)) if (drained >= 0) { pendingSteering.splice(drained, 1) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 667db4abb3..b45b7a826f 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -594,8 +594,9 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output).toContain('1 queued') - // A loop-authored steering event (plugin source, no matching agent/inbox/enqueue) - // cannot consume a pending user slot, even when it drains first. + // A steering/message whose source matches no pending badge entry (here a + // plugin source with no tracked enqueue) pops nothing, so it cannot consume + // a pending user slot even when it drains first. result.terminal.output = '' result.session.append('steering/message', { turn: 1, From b63abe80d6f90eb0ccdcef7372a5909a05fa4598 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 23 Jul 2026 19:54:31 +0800 Subject: [PATCH 03/37] docs(agent-note): add Chinese counterpart for unified-send Agent Note Bilingual pair required for docs dated on/after 2026-07-14; adds the .zh.md, the language-switcher lines, and the recorded .i18n.yaml sidecar. --- ...send-and-coalesced-user-messages.i18n.yaml | 6 +++ ...nified-send-and-coalesced-user-messages.md | 2 + ...ied-send-and-coalesced-user-messages.zh.md | 41 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml new file mode 100644 index 0000000000..00fe96d2f2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-22-unified-send-and-coalesced-user-messages.md: 61f9775c7b99c783a86e6a0814dc69d548ecbbe1 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d9ce4c482203592b8e5aada976debb6f088bb1eb diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 3bf721f0fd..61f9775c7b 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-22-unified-send-and-coalesced-user-messages.zh.md) + ## Problem The agent's public driving surface had grown three near-parallel verbs — `send`, `steer`, `inject` — each with its own options type, its own live event story, and its own durable event. `send` and `steer` both queued a frozen inbox record and emitted `agent/queued`; `inject` bypassed the inbox and wrote a separate `context/message` durable event. The three verbs actually vary along only two independent axes: which queue an item joins (a whole new turn versus the active turn) and whether the item makes the model run. Encoding that 2×2 as three hand-written methods hid the symmetry, made "queue a turn without waking the driver" unreachable, and left `cancel()` with no way to abort a turn while preserving queued work. diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md new file mode 100644 index 0000000000..d9ce4c4822 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 将 agent 投递统一到 send(target × wakeup) 并把注入的上下文合并进 user/message + +Status: implemented + +[English](2026-07-22-unified-send-and-coalesced-user-messages.md) | 中文 + +## 问题 + +agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`、`steer`、`inject`——各自带有独立的选项类型、独立的实时事件叙事,以及独立的持久事件。`send` 和 `steer` 都会把一条冻结的 inbox 记录入队并发出 `agent/queued`;`inject` 则绕过 inbox,写入一条独立的 `context/message` 持久事件。这三个动词实际上只沿两条独立的轴变化:一个队列项加入哪个队列(一个全新的轮次,还是当前活跃的轮次),以及这个队列项是否让模型运行。把这个 2×2 编码成三个手写方法,掩盖了其中的对称性,让“排入一个轮次但不唤醒驱动器”无法表达,也让 `cancel()` 无从在保留排队工作的前提下中止一个轮次。 + +另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都投影为逐字的 user 角色内容,唯一真正的区别是注入的上下文携带 `source`/`meta` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。 + +## 决策 + +**一个原语,三个预设别名。** `Agent` 现在是一个抽象类,其唯一的抽象方法 `send(content, { target, wakeup, source, contexts, meta })` 覆盖 (`target` × `wakeup`) 矩阵。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)是基类上的具体委托方法,因此具体驱动器只需实现一次 `send`,就能继承这些好用的预设。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`send` 默认使用 `{ target: 'next-turn', wakeup: true }`,因此此前每一次裸调用 `agent.send(content)` 都保持完全相同的行为。`next-turn`/no-wakeup(入队但不唤醒)现在可以表达,只是没有别名,也没有当前调用方。 + +**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:在当前日志位置追加的持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时的一次性 `injection` 轮次。它完全绕过 FIFO 队列,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 + +**context/message 已移除。** 注入的上下文现在是一条 `user/message`,其 `source` 为非 `user` 类别(plugin 或 goal)。`PromptMessageData` 新增了 `context/message` 原本携带的可选 `meta`。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。这让 goal-authority 的人类授权检查与此前一样严格——注入的消息默认使用 plugin 来源,永远无法满足 `source.kind === 'user'`。 + +**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,携带 `goal/change` 元数据;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 现在接收一条 `user/message`,并仍会在非 goal 来源携带 goal 元数据、或 goal 来源缺少元数据时立即报错。 + +**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO;在 `InboxItemInfo` 上携带 `target`/`wakeup`)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 + +**cancel 新增 keepInbox。** `cancel(cause?, { keepInbox? })`;当其为 true 时,它中止活跃轮次,但保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 + +## 考虑过的替代方案 + +- **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。注入的上下文改为默认使用 plugin 来源。 +- **在 `PromptMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。 +- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了 `target`/`wakeup` 的事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 + +## 后果 + +投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 + +## 相关 + +- [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。 +- [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md)——折叠镜像实时事件的先例。 +- [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md)——`keepInbox` 所扩展的取消原因信号。 From 3fd72f7c7470379139b9b75ad36721e4558f3716 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 23 Jul 2026 20:45:29 +0800 Subject: [PATCH 04/37] feat(agent): rename InboxItemInfo to AgentMessage with an id; send returns it Add a branded AgentMessageId assigned to each accepted send message and returned from send/followup/steer/inject (was void). Rename the inbox event payload InboxItemInfo to AgentMessage, carrying that id so a caller can correlate a queued item with its enqueue/dequeue/discard events. --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 4 +- ...ied-send-and-coalesced-user-messages.zh.md | 4 +- docs/cordis-catalog/events.md | 54 ++++++++--------- docs/core-data-structures/core.md | 44 ++++++++++---- docs/event-producer-consumer.md | 36 +++++------ .../time-context/tests/time-context.spec.ts | 9 +-- .../tests/workspace-context.spec.ts | 9 +-- .../cordis/tool-cordis/src/api-catalog.ts | 12 ++-- packages/core/agent-loop/src/agent.ts | 23 ++++--- packages/core/agent-loop/src/inbox.ts | 11 ++-- packages/core/agent-loop/src/loop.ts | 16 +++-- packages/core/agent-loop/tests/inbox.spec.ts | 3 +- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 60 +++++++++++++------ packages/core/agent/tests/agent.spec.ts | 3 +- packages/core/agent/tests/invariant.spec.ts | 4 +- packages/core/scope/tests/invariant.spec.ts | 6 +- .../command-goal/tests/command-goal.spec.ts | 10 ++-- .../goal-session/tests/goal-session.spec.ts | 4 +- packages/goal/goal/tests/goal.spec.ts | 11 ++-- .../goal/tool-goal/tests/tool-goal.spec.ts | 9 +-- packages/pty/pty-local/tests/index.spec.ts | 4 +- packages/pty/pty-local/tests/local.spec.ts | 4 +- packages/pty/pty/tests/service.spec.ts | 10 ++-- .../tool-pty/tests/loader-composition.spec.ts | 4 +- packages/pty/tool-pty/tests/tools.spec.ts | 4 +- packages/tasks/tasks/tests/tasks.spec.ts | 10 ++-- packages/ui/tui/tests/harness.ts | 6 +- packages/ui/tui/tests/tui.spec.ts | 22 +++---- scripts/gen-cordis-catalog.ts | 3 +- scripts/type-equiv.manifest.json | 7 ++- 32 files changed, 242 insertions(+), 170 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 00fe96d2f2..cbf529c8aa 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: 61f9775c7b99c783a86e6a0814dc69d548ecbbe1 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d9ce4c482203592b8e5aada976debb6f088bb1eb +2026-07-22-unified-send-and-coalesced-user-messages.md: d88cd60f5c09f7961d7a59dbbd0703abdd26cc1a +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d16ed9f763a350e61abc00d3971e4a7711e8f610 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 61f9775c7b..d88cd60f5c 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -20,7 +20,9 @@ Separately, `context/message` and `user/message` had converged: the surface proj **Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata. -**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO; carries `target`/`wakeup` on `InboxItemInfo`), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items). Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. +**`send` returns an id.** `send` (and the aliases) return an opaque branded `AgentMessageId` for the accepted message; `send`'s previous return was `void`. + +**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) each carry an `AgentMessage` — the accepted message including its returned `id`, `target`/`wakeup`, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. **cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped). diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index d9ce4c4822..d16ed9f763 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -20,7 +20,9 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` **goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,携带 `goal/change` 元数据;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 现在接收一条 `user/message`,并仍会在非 goal 来源携带 goal 元数据、或 goal 来源缺少元数据时立即报错。 -**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO;在 `InboxItemInfo` 上携带 `target`/`wakeup`)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 +**`send` 返回一个 id。** `send`(以及其别名)为被接受的消息返回一个不透明的 branded `AgentMessageId`;`send` 此前的返回值是 `void`。 + +**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、`target`/`wakeup`、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 **cancel 新增 keepInbox。** `cancel(cause?, { keepInbox? })`;当其为 true 时,它中止活跃轮次,但保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 7c9ae4bea8..d61b126661 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:329`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:269`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:293`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:477`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:501`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -108,16 +108,16 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, * boundary, or steering drained between steps. Fires after the item leaves * its FIFO and before it becomes a durable message. * @param agent - the agent whose inbox item was claimed. - * @param info - the claimed item's accepted content, source, contexts, steering, and wakeup facts. + * @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/dequeue'(this: Scoped, agent: Agent, info: InboxItemInfo): void +'agent/inbox/dequeue'(this: Scoped, agent: Agent, message: AgentMessage): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -129,16 +129,16 @@ Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/t * delivering them. Fires once per effective clearing call with every * discarded item, after `agent/cancel-requested` and before the abort. * @param agent - the agent whose inbox was cleared. - * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending. + * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/discard'(this: Scoped, agent: Agent, items: InboxItemInfo[]): void +'agent/inbox/discard'(this: Scoped, agent: Agent, messages: AgentMessage[]): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -152,16 +152,16 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou * is the eventual `user/message`/`steering/message`. Injection * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. * @param agent - the agent whose inbox received the item. - * @param info - the accepted content, source, contexts, steering, and wakeup facts. + * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/enqueue'(this: Scoped, agent: Agent, info: InboxItemInfo): void +'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: AgentMessage): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -184,7 +184,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:451`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -207,7 +207,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:382`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -234,7 +234,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:374`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:398`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -259,7 +259,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:388`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:412`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -285,7 +285,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:442`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:466`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -311,7 +311,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:403`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -333,7 +333,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:366`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -353,7 +353,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:287`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -376,7 +376,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) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:415`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:439`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -398,7 +398,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:453`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:477`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -420,7 +420,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:464`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:488`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index c2d8b56a80..69f917eaad 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -409,16 +409,30 @@ The fixed-preset aliases own `target` and `wakeup`, so they accept only the rema type AliasSendOptions = Omit ``` -The `agent/inbox/*` live events carry the resolved facts of one FIFO item; injection bypasses the FIFOs and never appears on them: +`send` returns the accepted message's opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events: ```ts type-equiv /** - * The resolved facts of one inbox FIFO item, carried by the `agent/inbox/*` - * live events. Source defaults are already applied, so these are the exact - * values the item was accepted with. `steering` is true for a `next-step` - * item drained between steps; a `next-turn` item is claimed at a turn boundary. + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. */ -interface InboxItemInfo { +type AgentMessageId = Branded<'AgentMessageId'> +``` + +The `agent/inbox/*` live events carry one accepted message; injection bypasses the FIFOs and never appears on them: + +```ts type-equiv +/** + * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. Source defaults are already + * applied, so these are the exact values the item was accepted with. `steering` + * is true for a `next-step` item drained between steps; a `next-turn` item is + * claimed at a turn boundary. + */ +interface AgentMessage { + /** The id `send` returned for this message. */ + id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] @@ -488,8 +502,9 @@ abstract class Agent { * input throws synchronously before any notification, enqueue, or append. * @param content - the model-facing content blocks to deliver. * @param options - target queue, wakeup decision, source, contexts, and meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - abstract send(content: ContentBlock[], options?: SendOptions): void + abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -512,9 +527,10 @@ abstract class Agent { * ordinary message of its own turn. * @param content - the prompt content blocks. * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - followup(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-turn', wakeup: true }) + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-turn', wakeup: true }) } /** @@ -526,9 +542,10 @@ abstract class Agent { * Idle steering falls back to a woken follow-up turn. * @param content - the steering content blocks. * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - steer(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-step', wakeup: true }) + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: true }) } /** @@ -541,9 +558,10 @@ abstract class Agent { * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. * @param content - the injected context content blocks. * @param options - source and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}. */ - inject(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-step', wakeup: false }) + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: false }) } } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0825bbf348..2cf7e3a577 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,24 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:329`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:269`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:477`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:309`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:358`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:374`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:388`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:442`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:403`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:342`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:287`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:415`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:453`](../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), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:464`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:353`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:293`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:501`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:323`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:451`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:382`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:398`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:412`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:466`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:366`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:439`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:477`](../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), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:488`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 26bb5d7a9d..c87915dda7 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -4,7 +4,7 @@ import Loader from '@cordisjs/plugin-loader' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -42,14 +42,15 @@ function sessionAgent(session: Session, id = 'agent'): Agent { session, status: 'running', ctx: new Context(), - send() {}, - followup() {}, - steer() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content, options) { session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, }, { surfaceOp: 'append' }) + return AgentMessageId('stub') }, cancel() {}, whenIdle: () => Promise.resolve(), diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index c96cee8b38..e04c92fd19 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId, type Agent, type HookContext } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { @@ -174,15 +174,16 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { options: {}, session, status: 'idle', - send() {}, - followup() {}, - steer() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content, options) { session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, ...options?.meta !== undefined ? { meta: options.meta } : {}, }, { surfaceOp: 'append' }) + return AgentMessageId('stub') }, cancel() {}, whenIdle: () => Promise.resolve(), diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index f5872f5fcc..9e6087a4de 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -846,22 +846,22 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/inbox/dequeue', mode: 'emit', - signature: '\'agent/inbox/dequeue\'(this: Scoped, agent: Agent, info: InboxItemInfo): void', - jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param info - the claimed item\'s accepted content, source, contexts, steering, and wakeup facts.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/dequeue\'(this: Scoped, agent: Agent, message: AgentMessage): void', + jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps.', }, { name: 'agent/inbox/discard', mode: 'emit', - signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, items: InboxItemInfo[]): void', - jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, messages: AgentMessage[]): void', + jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: '`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them.', }, { name: 'agent/inbox/enqueue', mode: 'emit', - signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, info: InboxItemInfo): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `info` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param info - the accepted content, source, contexts, steering, and wakeup facts.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, message: AgentMessage): void', + jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `info` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', }, { diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 670945a8fa..2ce1928af2 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -6,15 +6,16 @@ * @module dsh-agent-loop/agent */ +import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { agentEvents } from '@deepseek-ai/dsh-agent' +import { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' import { Agent } from '@deepseek-ai/dsh-agent' import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, SendOptions } from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts' -import { Inbox, inboxInfo, type InboxMessage } from './inbox.ts' +import { Inbox, agentMessage, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' /** Sessions already claimed by a concrete driver construction. */ @@ -196,9 +197,11 @@ export class ReactLoopAgent extends Agent { * materialization reads every nested field once; deep freeze prevents later * caller mutation before an inbox or deferred-injection queue drains it. */ - private acceptMessage(content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions): InboxMessage { + private acceptMessage( + id: AgentMessageId, content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions, + ): InboxMessage { const contexts = options?.contexts ?? [] - const accepted = snapshotJsonValue({ content, source, contexts, wakeup }) + const accepted = snapshotJsonValue({ id, content, source, contexts, wakeup }) if (accepted === undefined) { throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable') } @@ -219,23 +222,25 @@ export class ReactLoopAgent extends Agent { if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) } - send(content: ContentBlock[], options?: SendOptions): void { + send(content: ContentBlock[], options?: SendOptions): AgentMessageId { this.assertNotDisposed() + const id = AgentMessageId(randomUUID()) const target = options?.target ?? 'next-turn' const wakeup = options?.wakeup ?? true // next-step/no-wakeup is injection: durable context without running the model. - if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return } + if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return id } // next-step/wakeup is steering into the running turn; idle falls back to a // woken follow-up turn (there is no active turn to attach to). const steering = target === 'next-step' && this._status === 'running' const source = options?.source ?? { kind: 'user' } - const accepted = this.acceptMessage(content, source, wakeup, options) + const accepted = this.acceptMessage(id, content, source, wakeup, options) if (steering) { this.#inbox.steer(accepted) } else { this.#inbox.enqueue(accepted, wakeup) } - agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', inboxInfo(accepted, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', agentMessage(accepted, steering)) + return id } /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ @@ -346,7 +351,7 @@ export class ReactLoopAgent extends Agent { // Clear work already present before abort observers run. this.#inbox.clear() if (discarded.length > 0) { - const items = discarded.map(({ message, steering }) => inboxInfo(message, steering)) + const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) } } diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index e6bbbcfb7f..fd2810ea9a 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -7,10 +7,11 @@ */ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { HookContext, InboxItemInfo } from '@deepseek-ai/dsh-agent' +import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent' -/** One message waiting in an agent's inbox. */ +/** One message waiting in an agent's inbox; `id` is the value `send` returned. */ export interface InboxMessage { + id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] @@ -22,10 +23,10 @@ export interface InboxMessage { * Build the `agent/inbox/*` event payload for one inbox item. * @param message - the accepted inbox record. * @param steering - whether the item is in the steering FIFO (`next-step`). - * @returns the live-event facts for enqueue/dequeue/discard. + * @returns the live-event message for enqueue/dequeue/discard. */ -export function inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo { - return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } +export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage { + return { id: message.id, content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } } /** diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 11733b17e8..6b57f87e74 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -5,11 +5,12 @@ * @module dsh-agent-loop/loop */ +import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm' import { isDeepStrictEqual } from 'node:util' import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' -import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent' +import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent' import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' @@ -19,7 +20,7 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' -import { inboxInfo, type Inbox, type InboxMessage } from './inbox.ts' +import { agentMessage, type Inbox, type InboxMessage } from './inbox.ts' import type { TurnCancellation } from './cancellation.ts' /** Normalize thrown values while preserving an existing error code. */ @@ -279,7 +280,7 @@ async function runTurn( const drainSteering = (): boolean => { const messages = handle.inbox.drainSteering() for (const message of messages) { - events.emit('agent/inbox/dequeue', inboxInfo(message, true)) + events.emit('agent/inbox/dequeue', agentMessage(message, true)) const prepared = preparePromptMessage(message.content, message.source, message.contexts) session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' }) for (const context of prepared.separateContexts) { @@ -297,7 +298,7 @@ async function runTurn( const message = handle.inbox.dequeueQueued() /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ if (!message) throw new Error('runTurn invariant violated: no queued message at turn start') - events.emit('agent/inbox/dequeue', inboxInfo(message, false)) + events.emit('agent/inbox/dequeue', agentMessage(message, false)) const trigger: TurnTrigger = { kind: 'message', source: message.source } let reason: TurnEndReason = { kind: 'completed' } @@ -542,9 +543,12 @@ async function runTurn( // enqueue event a public steer would, so the inbox ledger stays balanced // (every FIFO entry has a matching enqueue before its dequeue/discard). if (decision.action === 'continue' && decision.reason) { - const item: InboxMessage = { content: decision.reason.content, source: decision.reason.source, contexts: [], wakeup: true } + const item: InboxMessage = { + id: AgentMessageId(randomUUID()), content: decision.reason.content, + source: decision.reason.source, contexts: [], wakeup: true, + } handle.inbox.steer(item) - events.emit('agent/inbox/enqueue', inboxInfo(item, true)) + events.emit('agent/inbox/enqueue', agentMessage(item, true)) } let shouldContinue = decision.action === 'continue' diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 91858e7050..791eae3bda 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest' +import { AgentMessageId } from '@deepseek-ai/dsh-agent' import { Inbox } from '../src/inbox.ts' function message(text: string) { - return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true } + return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true } } function resolverPair() { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 06a7e618f9..ebb756b139 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -56,7 +56,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/inbox/enqueue` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. It returns the accepted message's opaque `AgentMessageId`, which the message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry so a caller can correlate a queued item with its lifecycle. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/inbox/enqueue` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. - `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. - `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index f04c999cb6..e7d733c920 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -6,6 +6,7 @@ */ import type { Context } from 'cordis' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session' @@ -68,12 +69,31 @@ export interface SendOptions { export type AliasSendOptions = Omit /** - * The resolved facts of one inbox FIFO item, carried by the `agent/inbox/*` - * live events. Source defaults are already applied, so these are the exact - * values the item was accepted with. `steering` is true for a `next-step` - * item drained between steps; a `next-turn` item is claimed at a turn boundary. + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. */ -export interface InboxItemInfo { +export type AgentMessageId = Branded<'AgentMessageId'> + +/** + * Brand a string as an {@link AgentMessageId}. + * @param id - the generated message id. + * @returns the same string, branded; no validation is performed. + */ +export function AgentMessageId(id: string): AgentMessageId { + return id as AgentMessageId +} + +/** + * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. Source defaults are already + * applied, so these are the exact values the item was accepted with. `steering` + * is true for a `next-step` item drained between steps; a `next-turn` item is + * claimed at a turn boundary. + */ +export interface AgentMessage { + /** The id `send` returned for this message. */ + id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] @@ -194,8 +214,9 @@ export abstract class Agent { * input throws synchronously before any notification, enqueue, or append. * @param content - the model-facing content blocks to deliver. * @param options - target queue, wakeup decision, source, contexts, and meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - abstract send(content: ContentBlock[], options?: SendOptions): void + abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -218,9 +239,10 @@ export abstract class Agent { * ordinary message of its own turn. * @param content - the prompt content blocks. * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - followup(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-turn', wakeup: true }) + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-turn', wakeup: true }) } /** @@ -232,9 +254,10 @@ export abstract class Agent { * Idle steering falls back to a woken follow-up turn. * @param content - the steering content blocks. * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - steer(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-step', wakeup: true }) + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: true }) } /** @@ -247,9 +270,10 @@ export abstract class Agent { * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. * @param content - the injected context content blocks. * @param options - source and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}. */ - inject(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-step', wakeup: false }) + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: false }) } } @@ -292,31 +316,31 @@ declare module 'cordis' { * is the eventual `user/message`/`steering/message`. Injection * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. * @param agent - the agent whose inbox received the item. - * @param info - the accepted content, source, contexts, steering, and wakeup facts. + * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/enqueue'(this: Scoped, agent: Agent, info: InboxItemInfo): void + 'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: AgentMessage): void /** * The driver claimed one item out of the inbox: a queued item at a turn * boundary, or steering drained between steps. Fires after the item leaves * its FIFO and before it becomes a durable message. * @param agent - the agent whose inbox item was claimed. - * @param info - the claimed item's accepted content, source, contexts, steering, and wakeup facts. + * @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/dequeue'(this: Scoped, agent: Agent, info: InboxItemInfo): void + 'agent/inbox/dequeue'(this: Scoped, agent: Agent, message: AgentMessage): void /** * `cancel()` (without `keepInbox`) dropped pending inbox items without * delivering them. Fires once per effective clearing call with every * discarded item, after `agent/cancel-requested` and before the abort. * @param agent - the agent whose inbox was cleared. - * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending. + * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/discard'(this: Scoped, agent: Agent, items: InboxItemInfo[]): void + 'agent/inbox/discard'(this: Scoped, agent: Agent, messages: AgentMessage[]): void /** * Effective broad cancellation was requested, before queued/steering work * is cleared or the active turn is aborted. This observe-only notification diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index d157eeab57..aefa739dde 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -4,6 +4,7 @@ import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Agent, + AgentMessageId, agentEvents, agentInterruptReasonOf, } from '@deepseek-ai/dsh-agent' @@ -20,7 +21,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { session: new Session(id), status: 'idle', ctx: new Context(), - send() {}, + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, ...overrides, diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index b850e8743a..453752c402 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import { scopeTarget } from '@deepseek-ai/dsh-scope' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -58,7 +58,7 @@ describe('agent status invariants', () => { }) describe('agent inbox invariants', () => { - const info = (steering: boolean) => ({ content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true }) + const info = (steering: boolean) => ({ id: AgentMessageId('m'), content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true }) it('accepts a dequeue and a discard covered by prior enqueues', async () => { const ctx = await setup() diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index bcb3944cbd..ca0841165b 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { Events } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import { scopeTarget } from '@deepseek-ai/dsh-scope' import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -42,8 +42,8 @@ describe('scoped-dispatch invariants', () => { 'agent/created': [agent], 'agent/disposed': [agent], 'agent/status': [agent, 'idle'], - 'agent/inbox/enqueue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], - 'agent/inbox/dequeue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], + 'agent/inbox/enqueue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], + 'agent/inbox/dequeue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], 'agent/inbox/discard': [agent, []], 'agent/cancel-requested': [agent, { kind: 'user' }], 'agent/session-start': [agent, 'startup'], diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 6ea767e091..8964d02df2 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' @@ -48,10 +48,10 @@ function stubAgent(id: string): { agent: Agent; session: Session } { session, ctx: new Context(), get status() { return status }, - send() {}, - followup() {}, - steer() {}, - inject(content, options) { appendInjection(session, content, options) }, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') }, cancel() { status = 'idle' }, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index e323180e88..d4994466f9 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -488,7 +488,7 @@ describe('same-session goal driving', () => { if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') { throw new Error('queue rejected') } - realSend(content, options) + return realSend(content, options) }) test.ctx.goals.create(test.agent, { objective: 'handle queue failure' }) @@ -510,7 +510,7 @@ describe('same-session goal driving', () => { test.ctx.goals.disarm(test.agent) throw new Error('queue rejected after disarm') } - realSend(content, options) + return realSend(content, options) }) test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' }) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 18f8d5dfe2..4f6e4e2cfc 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -64,12 +64,13 @@ function stubAgentForSession(session: Session): StubAgent { session, ctx: new Context(), get status() { return status }, - send() {}, - followup() {}, - steer() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content, options) { if (shouldDefer) deferred.push({ content, options }) else appendInjection(session, content, options) + return AgentMessageId('stub') }, cancel() {}, whenIdle() { return Promise.resolve() }, @@ -476,7 +477,7 @@ describe('GoalService mutations', () => { let reject = true stub.agent.inject = (content, options) => { if (reject) throw new Error('injection rejected') - append(content, options) + return append(content, options) } ctx.agents.register(stub.agent) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 171fe1c0ef..a4f21392a3 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' @@ -31,9 +31,9 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { session, get status() { return status }, ctx: new Context(), - send() {}, - followup() {}, - steer() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content: ContentBlock[], options?: AliasSendOptions) { const source = options?.source ?? { kind: 'plugin', plugin: '' } session.append('user/message', { @@ -41,6 +41,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { source, ...options?.meta === undefined ? {} : { meta: options.meta }, }, { surfaceOp: 'append' }) + return AgentMessageId('stub') }, cancel() {}, whenIdle() { return Promise.resolve() }, diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 360b71c3f8..dffd80dfbd 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -3,7 +3,7 @@ import type { IPty, IPtyForkOptions } from 'node-pty' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index ad463a0352..512c84c6eb 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import PtyService from '@deepseek-ai/dsh-pty' import SandboxProvider from '@deepseek-ai/dsh-sandbox' @@ -34,7 +34,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 4a690ed520..91842173c8 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import PtyService, { PtyError, PtySessionId } from '@deepseek-ai/dsh-pty' import type { @@ -27,10 +27,10 @@ function stubAgent(ctx: Context, rawId: string): Agent { session: new Session(id), status: 'idle', ctx: scopeFiber.ctx, - send() {}, - followup() {}, - steer() {}, - inject() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index f71a637928..cf096ebb70 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -8,7 +8,7 @@ import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 0439e5f876..b0da3adb27 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -17,7 +17,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index b4d57191a2..cb72658968 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' @@ -23,10 +23,10 @@ function stubAgent(ctx: Context, rawId: string): Agent { session: new Session(id), status: 'idle' as const, ctx: scopeFiber.ctx, - send() {}, - followup() {}, - steer() {}, - inject() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index c8755e4bb3..243f492d2c 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -1,6 +1,7 @@ import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { + AgentMessageId, type Agent, type AgentCancelCause, type AgentOptions, @@ -152,16 +153,19 @@ export async function createTuiTestHarness AgentMessageId('stub'), cancel(cause = { kind: 'user' }) { cancelled.push(cause) }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index b45b7a826f..23e297783c 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3,7 +3,7 @@ import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, assembleContextFor, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import { type LlmCallConfig } from '@deepseek-ai/dsh-llm' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type JsonValue, type SessionHeader } from '@deepseek-ai/dsh-session' @@ -552,7 +552,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).not.toContain('queued') const queueSteering = (text: string): void => { - result.ctx.emit('agent/inbox/enqueue', result.agent, { content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) + result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) } const drainSteering = (text: string): void => { result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -561,7 +561,7 @@ describe('pi-tui chat lifecycle and transcript', () => { // A steering queue for a different agent never touches this status line. const other = { ...result.agent, id: SessionId('other') } as unknown as Agent result.terminal.output = '' - result.ctx.emit('agent/inbox/enqueue', other, { content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) + result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) await tick() expect(result.terminal.output).not.toContain('queued') @@ -574,7 +574,7 @@ describe('pi-tui chat lifecycle and transcript', () => { // A non-steering queue (an idle-style send) leaves the badge untouched. result.terminal.output = '' - result.ctx.emit('agent/inbox/enqueue', result.agent, { content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }) + result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }) drainSteering('first') await tick() expect(result.terminal.output).toContain('1 queued') @@ -628,7 +628,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const idle = await setup() // A steering queue arriving while idle has no status line to badge, so the // refresh is a no-op beyond requesting a render. - idle.ctx.emit('agent/inbox/enqueue', idle.agent, { content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) + idle.ctx.emit('agent/inbox/enqueue', idle.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' }) await tick() expect(idle.terminal.output).not.toContain('Executing tools') @@ -2208,7 +2208,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) @@ -2232,7 +2232,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -2266,14 +2266,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -2303,7 +2303,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -2345,7 +2345,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 2eee7e0bb0..800f59c22e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -35,8 +35,9 @@ export const LINK_MAP: Record = { ContinuationDecision: 'core.md', ContinuationStop: 'core.md', GenerateOptions: 'core.md', + AgentMessage: 'core.md', + AgentMessageId: 'core.md', HookContext: 'core.md', - InboxItemInfo: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', LlmFailure: 'llm-streaming.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5014148504..45c08e494a 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -83,7 +83,12 @@ }, { "doc": "docs/core-data-structures/core.md", - "symbol": "InboxItemInfo", + "symbol": "AgentMessageId", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "AgentMessage", "source": "packages/core/agent/src/types.ts" }, { From 866b5437009b5e899c53db9ce3a73b6348713a1b Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 23 Jul 2026 20:50:13 +0800 Subject: [PATCH 05/37] docs(agent): fix stale 'info' param name in agent/inbox/enqueue JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @param was renamed info→message; the body sentence still named info. Found by fresh-eye review. Regenerated the catalog/graph docs. --- docs/cordis-catalog/events.md | 4 ++-- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent/src/types.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d61b126661..7f1015a691 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -142,12 +142,12 @@ Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/t ### `agent/inbox/enqueue` — emit -A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `info` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. +A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. ```ts cordis-catalog /** * A detached, frozen item entered the agent's inbox (queued or steering - * FIFO). Source defaults are already applied, so `info` holds the exact + * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record * is the eventual `user/message`/`steering/message`. Injection * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9e6087a4de..293a514c74 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -861,7 +861,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/enqueue', mode: 'emit', signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, message: AgentMessage): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `info` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', }, { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index e7d733c920..9986affe6b 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -311,7 +311,7 @@ declare module 'cordis' { 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** * A detached, frozen item entered the agent's inbox (queued or steering - * FIFO). Source defaults are already applied, so `info` holds the exact + * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record * is the eventual `user/message`/`steering/message`. Injection * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. From c402cc24225d666ade96b8731e7ef56a5cb46dd9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 23 Jul 2026 20:58:35 +0800 Subject: [PATCH 06/37] test(snapshot): re-record acp fixtures after merging master Merge took master's snapshot fixtures; re-record the two keyless scenarios so they reflect this branch's user/message coalescing and the abstract Agent + AgentMessageId type dump. --- .../tests/snapshots/code-mode-workspace-context/session.jsonl | 2 +- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- .../tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 88152d69cd..3f664e8e09 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -87,7 +87,7 @@ {"type":"tool/call","seq":85,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content.lines.map(line => line.text).join(String.fromCharCode(10));\"}"}} {"type":"tool/code-dispatch","seq":86,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"./nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}} {"type":"tool/result","seq":87,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"Touch this file to discover the nested workspace instruction."}],"isError":false},"sourceEventSeqs":[85],"surfaceOp":"append"} -{"type":"context/message","seq":88,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} +{"type":"user/message","seq":88,"time":1784811336862,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} {"type":"step/end","seq":89,"time":1783921767272,"data":{"turn":1,"step":1}} {"type":"step/start","seq":90,"time":1783921767272,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":91,"time":1783921768339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 7238728b48..7153e81bbc 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 56fc0733c4..b602c679e0 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} From 98ee4ce429ab473725ee250771a6991db012b853 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 23 Jul 2026 21:41:42 +0800 Subject: [PATCH 07/37] =?UTF-8?q?fix(agent-loop):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20quiet-item=20parking,=20meta,=20discard=20balance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve six review findings on the unified-send change: - quiet (wakeup:false) queued items no longer un-park the driver; the inbox distinguishes hasWakingQueued (drives the loop, idle/quiescence) from hasQueued (anything to dequeue), so a lone quiet item parks at idle and rides the next waking send. whenIdle/cancel settle off the waking signal, so cancelling a parked quiet item no longer hangs whenIdle. - SendOptions.meta on queued/steering sends now reaches the durable user/message and steering/message (was dropped except on injection). - a terminal agent/turn-stop that drops pending steering emits agent/inbox/discard so the enqueue-dequeue-or-discard ledger balances. - the loop-authored continuation reason is snapshotted and frozen like a public send. - gen-cordis-api collects exported classes (body-stripped) so the now- abstract-class Agent and its transitive shapes reappear in the API catalog. Adds regression tests for each and re-records the affected snapshot. --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 2 + ...ied-send-and-coalesced-user-messages.zh.md | 2 + .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 48 +++++++++++++++++++ packages/core/agent-loop/src/agent.ts | 23 ++++++++- packages/core/agent-loop/src/inbox.ts | 18 ++++++- packages/core/agent-loop/src/loop.ts | 46 +++++++++++++----- packages/core/agent-loop/tests/cancel.spec.ts | 32 +++++++++++++ .../agent-loop/tests/inbox-invariant.spec.ts | 31 ++++++++++++ packages/core/agent-loop/tests/loop.spec.ts | 22 +++++++++ scripts/gen-cordis-api.ts | 37 ++++++++++++-- 13 files changed, 245 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index cbf529c8aa..367a7e9ef5 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: d88cd60f5c09f7961d7a59dbbd0703abdd26cc1a -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d16ed9f763a350e61abc00d3971e4a7711e8f610 +2026-07-22-unified-send-and-coalesced-user-messages.md: e969012c8172bbba78f04943be46b7dc866ccb7f +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 35cc183a7c7bfd8a649557e50e6e7d379f8f8193 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index d88cd60f5c..e969012c81 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -36,6 +36,8 @@ Separately, `context/message` and `user/message` had converged: the surface proj The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. +`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection. Every FIFO exit publishes exactly one lifecycle event: a terminal `agent/turn-stop` that drops pending steering now emits `agent/inbox/discard` for it, and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. + ## Related - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on. diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index d16ed9f763..35cc183a7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -36,6 +36,8 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` 投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性 `agent/turn-stop` 现在会为它发出 `agent/inbox/discard`,而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 + ## 相关 - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。 diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 7153e81bbc..d264fe9a67 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n private log: SessionEvent[];\n private readonly surfaceManager;\n get surface(): SessionSurface {\n return this.surfaceManager;\n }\n readonly header: SessionHeader;\n get id(): SessionId {\n return this.header.id;\n }\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n private eventsSnapshot: readonly SessionEvent[] | undefined;\n get events(): readonly SessionEvent[] {\n this.eventsSnapshot ??= Object.freeze([...this.log]);\n return this.eventsSnapshot;\n }\n get seq(): number {\n return this.log.length;\n }\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n private headerFold: EpochHeader | undefined;\n private headerFoldSeq;\n requestHeader(): EpochHeader | undefined;\n private derived: Message[];\n private derivedNodes;\n private derivedGeneration;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index b602c679e0..db7029cf50 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n private log: SessionEvent[];\n private readonly surfaceManager;\n get surface(): SessionSurface {\n return this.surfaceManager;\n }\n readonly header: SessionHeader;\n get id(): SessionId {\n return this.header.id;\n }\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n private eventsSnapshot: readonly SessionEvent[] | undefined;\n get events(): readonly SessionEvent[] {\n this.eventsSnapshot ??= Object.freeze([...this.log]);\n return this.eventsSnapshot;\n }\n get seq(): number {\n return this.log.length;\n }\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n private headerFold: EpochHeader | undefined;\n private headerFoldSeq;\n requestHeader(): EpochHeader | undefined;\n private derived: Message[];\n private derivedNodes;\n private derivedGeneration;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 4d820084b5..14a2a9ca52 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1151,6 +1151,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ + { + name: 'Agent', + declaration: 'export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n}', + }, + { + name: 'AgentCancelCause', + declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n};', + }, { name: 'AgentFactory', declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise;\n}', @@ -1159,10 +1167,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentHandle', declaration: 'export interface AgentHandle {\n agent: Agent;\n dispose(): Promise;\n}', }, + { + name: 'AgentMessageId', + declaration: 'export type AgentMessageId = Branded<\'AgentMessageId\'>;', + }, { name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n}', }, + { + name: 'AgentStatus', + declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';', + }, + { + name: 'AliasSendOptions', + declaration: 'export type AliasSendOptions = Omit;', + }, { name: 'ApprovalOutcome', declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';', @@ -1255,6 +1275,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CallId', declaration: 'export type CallId = Branded<\'CallId\'>;', }, + { + name: 'CancelOptions', + declaration: 'export interface CancelOptions {\n keepInbox?: boolean;\n}', + }, { name: 'CodeBindingErrorClass', declaration: 'export interface CodeBindingErrorClass {\n name: string;\n memberNameProperty: string;\n}', @@ -1495,6 +1519,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', }, + { + name: 'LlmAdapter', + declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise;\n resolveModelContext(_provider: string, _model: string): Promise;\n abstract stream(options: GenerateOptions): AsyncIterable;\n}', + }, { name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', @@ -1695,6 +1723,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ScopeKey', declaration: 'export type ScopeKey = object;', }, + { + name: 'SendOptions', + declaration: 'export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}', + }, + { + name: 'SendTarget', + declaration: 'export type SendTarget = \'next-turn\' | \'next-step\';', + }, + { + name: 'Session', + declaration: 'export class Session {\n private log: SessionEvent[];\n private readonly surfaceManager;\n get surface(): SessionSurface {\n return this.surfaceManager;\n }\n readonly header: SessionHeader;\n get id(): SessionId {\n return this.header.id;\n }\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n private eventsSnapshot: readonly SessionEvent[] | undefined;\n get events(): readonly SessionEvent[] {\n this.eventsSnapshot ??= Object.freeze([...this.log]);\n return this.eventsSnapshot;\n }\n get seq(): number {\n return this.log.length;\n }\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n private headerFold: EpochHeader | undefined;\n private headerFoldSeq;\n requestHeader(): EpochHeader | undefined;\n private derived: Message[];\n private derivedNodes;\n private derivedGeneration;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + }, { name: 'SessionEvent', declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', @@ -1767,6 +1807,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionReferenceInput', declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}', }, + { + name: 'SessionSurface', + declaration: 'export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n}', + }, { name: 'SessionSurfaceSnapshot', declaration: 'export interface SessionSurfaceSnapshot {\n session: SessionHeader;\n capturedThroughSeq: number | null;\n events: SurfaceEvent[];\n}', @@ -1899,6 +1943,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SurfaceEventType', declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';', }, + { + name: 'SurfaceIntent', + declaration: 'export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n}', + }, { name: 'SurfaceOp', declaration: 'export type SurfaceOp = \'append\' | {\n op: \'replace\';\n start: number;\n end: number;\n};', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 2ce1928af2..7c2dfd0f60 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -201,7 +201,10 @@ export class ReactLoopAgent extends Agent { id: AgentMessageId, content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions, ): InboxMessage { const contexts = options?.contexts ?? [] - const accepted = snapshotJsonValue({ id, content, source, contexts, wakeup }) + const accepted = snapshotJsonValue({ + id, content, source, contexts, wakeup, + ...options?.meta !== undefined ? { meta: options.meta } : {}, + }) if (accepted === undefined) { throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable') } @@ -344,6 +347,11 @@ export class ReactLoopAgent extends Agent { agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause) } if (!keepInbox) { + // Whether the parked driver was already scheduled to run: a waking item + // woke `waitForQueued`, so the loop WILL resume and settle idle waiters + // itself through the pre-run-cancel path (possibly after a replacement + // prompt). Only a lone quiet item leaves the loop truly parked. + const willResume = this.#inbox.hasWakingQueued // Snapshot before clearing so the discard notification carries the exact // dropped items; a replacement synchronously enqueued by an // `agent/cancel-requested` observer belongs to the next turn, not here. @@ -354,6 +362,15 @@ export class ReactLoopAgent extends Agent { const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) } + // Clearing a parked quiet (`wakeup:false`) item reaches quiescence with no + // status transition and without waking the parked driver, so settle any + // `whenIdle` waiter here. When a waking item was present the loop resumes + // and settles itself; while `running` (including the post-turn flush + // window) the driver still owns the eventual idle transition. So settle + // only for a parked, non-running agent whose sole cleared work was quiet. + if (cancellation === undefined && !willResume && this._status !== 'running') { + this.settleIdleWaiters() + } } cancellation?.request(resolvedCause) } @@ -365,7 +382,9 @@ export class ReactLoopAgent extends Agent { */ whenIdle(): Promise { if (this._status === 'disposed') return this.done - if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve() + // A lone quiet (`wakeup:false`) queued item leaves the agent quiescent — the + // driver stays parked — so gate on hasWakingQueued, not hasQueued. + if (this._status !== 'running' && !this.#inbox.hasWakingQueued) return Promise.resolve() // Agent-owned waiters survive concurrent fiber disposal. return new Promise((resolve) => { this.idleWaiters.push(() => { diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index fd2810ea9a..3cb82944e4 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -7,6 +7,7 @@ */ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from '@deepseek-ai/dsh-session' import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent' /** One message waiting in an agent's inbox; `id` is the value `send` returned. */ @@ -17,6 +18,8 @@ export interface InboxMessage { contexts: HookContext[] /** Whether the item is marked to wake the driver or force a continuation. */ wakeup: boolean + /** Opaque durable JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue } /** @@ -39,11 +42,22 @@ export class Inbox { private steeringMessages: InboxMessage[] = [] private wakeup: (() => void) | undefined - /** True while queued messages are pending — read by the idle wait's fast path and the loop's turn-start checks. */ + /** True while any queued message is pending — read by cancellation's discard snapshot and the turn-start dequeue guard. */ get hasQueued(): boolean { return this.queuedMessages.length > 0 } + /** + * True while a queued message wants to wake the driver — the "should the loop + * run" signal read by the idle wait's fast path, the loop's idle-publish + * check, and `whenIdle`. A `wakeup:false` (quiet) item alone leaves this + * false, so the driver stays parked until a waking send (or a waking item + * ahead of it in FIFO order) drives the loop; the quiet item then rides along. + */ + get hasWakingQueued(): boolean { + return this.queuedMessages.some(message => message.wakeup) + } + /** True while steering messages are pending — read by cancellation and the loop's stop-override check. */ get hasSteering(): boolean { return this.steeringMessages.length > 0 @@ -116,7 +130,7 @@ export class Inbox { * loop can exit). */ waitForQueued(cancel: Promise): Promise { - if (this.hasQueued) return Promise.resolve() + if (this.hasWakingQueued) return Promise.resolve() const { promise, resolve } = Promise.withResolvers() this.wakeup = resolve void cancel.then(resolve) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 6b57f87e74..86d7c106f2 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -202,9 +202,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { while (!handle.isDisposed()) { // An idle listener can enqueue and cancel replacement work before the next // wait is installed. Consume that empty marker before parking the driver. + // A quiet (`wakeup:false`) item alone must not un-park the loop, so gate on + // hasWakingQueued, not hasQueued. if (handle.isPreRunCancelled()) { handle.clearPreRunCancel() - if (!handle.inbox.hasQueued) { + if (!handle.inbox.hasWakingQueued) { handle.settleIdle() handle.setStatus('idle') continue @@ -218,7 +220,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { // a replacement prompt still runs before the eventual idle transition. if (handle.isPreRunCancelled()) { handle.clearPreRunCancel() - if (!handle.inbox.hasQueued) { + if (!handle.inbox.hasWakingQueued) { // Settle before publishing idle: the already-idle path has no status // transition, while an idle listener can register waiters for new work. handle.settleIdle() @@ -235,10 +237,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { } // A synchronous `running` listener can cancel before `runTurn`; balance the - // status only when no replacement prompt was queued by that listener. + // status only when no waking replacement prompt was queued by that listener + // (a lone quiet item parks at idle rather than driving a turn). if (cancellation.signal.aborted) { handle.clearTurnCancellation(cancellation) - if (!handle.inbox.hasQueued) { + if (!handle.inbox.hasWakingQueued) { handle.setStatus('idle') continue } @@ -266,7 +269,9 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { if (!terminalStopped) handle.inbox.enqueue(message) } - if (!handle.inbox.hasQueued) handle.setStatus('idle') + // Park at idle unless a waking item still wants the model to run; a lone + // quiet (`wakeup:false`) item stays queued but does not keep the loop busy. + if (!handle.inbox.hasWakingQueued) handle.setStatus('idle') } } @@ -282,7 +287,10 @@ async function runTurn( for (const message of messages) { events.emit('agent/inbox/dequeue', agentMessage(message, true)) const prepared = preparePromptMessage(message.content, message.source, message.contexts) - session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' }) + session.append('steering/message', { + turn, ...prepared.data, + ...message.meta === undefined ? {} : { meta: message.meta }, + }, { surfaceOp: 'append' }) for (const context of prepared.separateContexts) { session.append('user/message', { content: context.content, @@ -364,7 +372,10 @@ async function runTurn( // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them. const content = promptDecision.content ?? message.content const prepared = preparePromptMessage(content, message.source, promptDecision.additionalContexts ?? []) - session.append('user/message', prepared.data, { surfaceOp: 'append' }) + session.append('user/message', { + ...prepared.data, + ...message.meta === undefined ? {} : { meta: message.meta }, + }, { surfaceOp: 'append' }) // Separate contexts still enter THIS turn through inject(). Prefix // contexts are already baked into the user/message with their durable // display envelope, so appending them again would duplicate model input. @@ -543,10 +554,15 @@ async function runTurn( // enqueue event a public steer would, so the inbox ledger stays balanced // (every FIFO entry has a matching enqueue before its dequeue/discard). if (decision.action === 'continue' && decision.reason) { - const item: InboxMessage = { - id: AgentMessageId(randomUUID()), content: decision.reason.content, - source: decision.reason.source, contexts: [], wakeup: true, - } + // Detach and freeze the listener-owned reason like a public steer, so an + // enqueue listener or the producer cannot mutate the durable/model-visible + // steering message before it drains. + const item: InboxMessage = deepFreeze({ + id: AgentMessageId(randomUUID()), + content: structuredClone(decision.reason.content), + source: structuredClone(decision.reason.source), + contexts: [], wakeup: true, + }) handle.inbox.steer(item) events.emit('agent/inbox/enqueue', agentMessage(item, true)) } @@ -572,7 +588,13 @@ async function runTurn( if (terminalStop) { terminalStopped = true // Terminal stop discards steering but preserves ordinary queued prompts. - handle.inbox.drainSteering() + // Publish a discard for every dropped steering item so the enqueue ⇒ + // dequeue-or-discard ledger stays balanced (the outstanding-count + // invariant and correlation consumers must not be left with dangling ids). + const dropped = handle.inbox.drainSteering() + if (dropped.length > 0) { + events.emit('agent/inbox/discard', dropped.map(item => agentMessage(item, true))) + } shouldContinue = false } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index b7d48ffd68..3a7af2871f 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -117,6 +117,38 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['preserved', 'wake it']) }) + it('a lone quiet (wakeup:false) send leaves the agent parked at idle', async () => { + const adapter = new MockAdapter([textResponse('reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + // A quiet item alone must NOT wake the driver: no turn runs and whenIdle + // resolves (the agent is quiescent), leaving the item queued. + agent.send([{ type: 'text', text: 'quiet' }], { target: 'next-turn', wakeup: false }) + await agent.whenIdle() + expect(agent.status).toBe('idle') + expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + + // A later waking send drives the loop, and the quiet item rides along first. + send(agent, 'wake') + await waitForIdle(ctx, agent) + expect(userTexts(agent)).toEqual(['quiet', 'wake']) + }) + + it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => { + const adapter = new MockAdapter([textResponse('reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'quiet' }], { target: 'next-turn', wakeup: false }) + const idle = agent.whenIdle() + // Cancel reaches quiescence with no status transition and no waking send; + // whenIdle must still resolve (previously it hung until the next send). + agent.cancel({ kind: 'user' }) + await idle + expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + }) + it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/inbox-invariant.spec.ts b/packages/core/agent-loop/tests/inbox-invariant.spec.ts index ac84c3d157..1d3e5b0360 100644 --- a/packages/core/agent-loop/tests/inbox-invariant.spec.ts +++ b/packages/core/agent-loop/tests/inbox-invariant.spec.ts @@ -86,4 +86,35 @@ describe('inbox FIFO-conservation invariant', () => { expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) }) + + it('stays balanced when a terminal stop discards pending steering', async () => { + const adapter = new MockAdapter([textResponse('done')]) + const ctx = await harness(adapter) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + const discards: number[] = [] + ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) }) + + // A continuation reason enqueues a steering item; a terminal stop then drops + // it. The drop must emit a discard so the enqueue ⇒ dequeue-or-discard + // ledger stays balanced (no dangling outstanding id). + ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => { + if (subject !== agent) return next() + return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } + }) + let stopped = false + ctx.on('agent/turn-stop', (subject) => { + if (subject !== agent || stopped) return undefined + stopped = true + return { action: 'stop' as const } + }) + + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(discards).toEqual([1]) // the dropped steering item was reported + expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) + }) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 439611d12b..8d3016d872 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -526,6 +526,28 @@ describe('agent loop', () => { expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) }) + it('preserves SendOptions.meta on the durable user/message and steering/message', async () => { + const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) + const ctx = await harness(adapter) + ctx.tools.register(defineContentToolFixture({ + name: 'noop', description: '', parameters: {}, + async execute() { + // Running steer carries its own meta onto the durable steering/message. + agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'p' }, meta: { steer: 1 } }) + return [] + }, + })) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }], { target: 'next-turn', wakeup: true, meta: { prompt: 1 } }) + await waitForIdle(ctx, agent) + + const user = agent.session.events.find(e => e.type === 'user/message') + expect(user?.type === 'user/message' && user.data.meta).toEqual({ prompt: 1 }) + const steering = agent.session.events.find(e => e.type === 'steering/message') + expect(steering?.type === 'steering/message' && steering.data.meta).toEqual({ steer: 1 }) + }) + it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => { // force-continue: model never calls tools, but a plugin forces 3 steps const adapter = new MockAdapter([ diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index 7c61b9395d..38cba69427 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -30,8 +30,35 @@ function quote(value: string): string { } /** - * Collect exported interface and type shapes; omit names declared in multiple - * packages rather than risk serving the wrong package's shape. + * Reduce an exported class to its type shape: drop method/constructor bodies + * and property initializers so the catalog serves member signatures, not + * implementation. An abstract class (e.g. `Agent`) is a public type consumers + * program against, so it belongs in the type closure alongside interfaces. + */ +function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration { + const members = node.members.map((member): ts.ClassElement => { + if (ts.isMethodDeclaration(member)) { + return ts.factory.updateMethodDeclaration( + member, member.modifiers, member.asteriskToken, member.name, member.questionToken, + member.typeParameters, member.parameters, member.type, undefined) + } + if (ts.isConstructorDeclaration(member)) { + return ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined) + } + if (ts.isPropertyDeclaration(member)) { + return ts.factory.updatePropertyDeclaration( + member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined) + } + return member + }) + return ts.factory.updateClassDeclaration( + node, node.modifiers, node.name, node.typeParameters, node.heritageClauses, members) +} + +/** + * Collect exported interface, type-alias, and (body-stripped) class shapes; + * omit names declared in multiple packages rather than risk serving the wrong + * package's shape. */ function collectTypeDecls(scanRoot: string = root): Map { const printer = ts.createPrinter({ removeComments: true }) @@ -41,14 +68,16 @@ function collectTypeDecls(scanRoot: string = root): Map { const abs = resolve(scanRoot, rel) const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true) for (const stmt of sf.statements) { - if (!ts.isInterfaceDeclaration(stmt) && !ts.isTypeAliasDeclaration(stmt)) continue + const named = ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isClassDeclaration(stmt) + if (!named || stmt.name === undefined) continue if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) continue const name = stmt.name.text if (decls.has(name)) { ambiguous.add(name) continue } - const printed = printer.printNode(ts.EmitHint.Unspecified, stmt, sf).replace(/\r/g, '') + const emit = ts.isClassDeclaration(stmt) ? classShape(stmt) : stmt + const printed = printer.printNode(ts.EmitHint.Unspecified, emit, sf).replace(/\r/g, '') decls.set(name, printed.length > MAX_DECL_CHARS ? `${printed.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */` : printed) From 1f9a3e1bee281707da3a971c1b54e52d10dd0554 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 23 Jul 2026 22:33:12 +0800 Subject: [PATCH 08/37] =?UTF-8?q?fix(agent-loop):=20second=20review=20pass?= =?UTF-8?q?=20=E2=80=94=20late-steering=20discard,=20dead-branch,=20catalo?= =?UTF-8?q?g=20leaks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address a second fresh-eye review of the review fixes: - MAJOR: late steering that lands after runTurn returns terminally stopped (e.g. during the post-turn flush) was drained by runLoop and dropped without a discard, leaving a dangling outstanding id the negative-only invariant can't catch. Emit agent/inbox/discard for it, symmetric with the in-turn terminal-stop drop. - remove the dead cancel() idle-settle branch: whenIdle's fast path already resolves for a lone quiet item, so no waiter is ever left for it to settle. Document why. - gen-cordis-api classShape now drops private/protected/#private members and strips getter/setter bodies, so Session no longer leaks private fields and getter bodies into the model catalog. - document that AgentMessage intentionally omits meta (durable-only). Adds a regression test for the late-steering discard. --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +-- ...nified-send-and-coalesced-user-messages.md | 2 +- ...ied-send-and-coalesced-user-messages.zh.md | 2 +- docs/cordis-catalog/events.md | 36 +++++++++---------- docs/core-data-structures/core.md | 4 ++- docs/event-producer-consumer.md | 36 +++++++++---------- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/src/agent.ts | 20 ++++------- packages/core/agent-loop/src/loop.ts | 14 ++++++-- .../agent-loop/tests/inbox-invariant.spec.ts | 35 ++++++++++++++++++ packages/core/agent/src/types.ts | 4 ++- scripts/gen-cordis-api.ts | 28 +++++++++++---- 14 files changed, 122 insertions(+), 69 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 367a7e9ef5..7c3ed96243 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: e969012c8172bbba78f04943be46b7dc866ccb7f -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 35cc183a7c7bfd8a649557e50e6e7d379f8f8193 +2026-07-22-unified-send-and-coalesced-user-messages.md: 9eb355128b217a0ea8dc09daf4e83334f6aeaa10 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 679c9100aa49777b7b601725bdfc077f5f2dab0e diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index e969012c81..9eb355128b 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -36,7 +36,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. -`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection. Every FIFO exit publishes exactly one lifecycle event: a terminal `agent/turn-stop` that drops pending steering now emits `agent/inbox/discard` for it, and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. +`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it — both at the in-turn stop point and on the post-turn drain of late steering — and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 35cc183a7c..679c9100aa 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -36,7 +36,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` 投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性 `agent/turn-stop` 现在会为它发出 `agent/inbox/discard`,而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`——既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时——而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 ## 相关 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 0640893d63..56c927e0db 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:293`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:501`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:503`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -138,7 +138,7 @@ Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/t Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -161,7 +161,7 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:325`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -184,7 +184,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:451`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:453`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -207,7 +207,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:382`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:384`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -234,7 +234,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:398`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -259,7 +259,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:412`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:414`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -285,7 +285,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:466`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:468`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -311,7 +311,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:429`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -333,7 +333,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:366`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -353,7 +353,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -376,7 +376,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) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:439`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:441`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -398,7 +398,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:477`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:479`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -420,7 +420,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:488`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:490`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6ccfec950b..b35ad8ea8d 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -428,7 +428,9 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t * message's enqueue, dequeue, and discard events. Source defaults are already * applied, so these are the exact values the item was accepted with. `steering` * is true for a `next-step` item drained between steps; a `next-turn` item is - * claimed at a turn boundary. + * claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is + * durable model-hidden state that lands on the eventual `user/message`/ + * `steering/message`, not live-event routing data. */ interface AgentMessage { /** The id `send` returned for this message. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 226b93f7ad..af0292af8c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,24 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:353`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:293`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:501`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:323`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:451`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:382`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:398`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:412`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:466`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:366`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:439`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:477`](../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), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:488`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:503`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:335`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:325`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:453`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:384`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:400`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:414`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:468`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:429`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:441`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:479`](../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), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:490`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index d264fe9a67..0ca32f7908 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n private log: SessionEvent[];\n private readonly surfaceManager;\n get surface(): SessionSurface {\n return this.surfaceManager;\n }\n readonly header: SessionHeader;\n get id(): SessionId {\n return this.header.id;\n }\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n private eventsSnapshot: readonly SessionEvent[] | undefined;\n get events(): readonly SessionEvent[] {\n this.eventsSnapshot ??= Object.freeze([...this.log]);\n return this.eventsSnapshot;\n }\n get seq(): number {\n return this.log.length;\n }\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n private headerFold: EpochHeader | undefined;\n private headerFoldSeq;\n requestHeader(): EpochHeader | undefined;\n private derived: Message[];\n private derivedNodes;\n private derivedGeneration;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index db7029cf50..e935daa76c 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n private log: SessionEvent[];\n private readonly surfaceManager;\n get surface(): SessionSurface {\n return this.surfaceManager;\n }\n readonly header: SessionHeader;\n get id(): SessionId {\n return this.header.id;\n }\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n private eventsSnapshot: readonly SessionEvent[] | undefined;\n get events(): readonly SessionEvent[] {\n this.eventsSnapshot ??= Object.freeze([...this.log]);\n return this.eventsSnapshot;\n }\n get seq(): number {\n return this.log.length;\n }\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n private headerFold: EpochHeader | undefined;\n private headerFoldSeq;\n requestHeader(): EpochHeader | undefined;\n private derived: Message[];\n private derivedNodes;\n private derivedGeneration;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 14a2a9ca52..f6ec27186d 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1733,7 +1733,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Session', - declaration: 'export class Session {\n private log: SessionEvent[];\n private readonly surfaceManager;\n get surface(): SessionSurface {\n return this.surfaceManager;\n }\n readonly header: SessionHeader;\n get id(): SessionId {\n return this.header.id;\n }\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n private eventsSnapshot: readonly SessionEvent[] | undefined;\n get events(): readonly SessionEvent[] {\n this.eventsSnapshot ??= Object.freeze([...this.log]);\n return this.eventsSnapshot;\n }\n get seq(): number {\n return this.log.length;\n }\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n private headerFold: EpochHeader | undefined;\n private headerFoldSeq;\n requestHeader(): EpochHeader | undefined;\n private derived: Message[];\n private derivedNodes;\n private derivedGeneration;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, { name: 'SessionEvent', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 7c2dfd0f60..baa2e3f08d 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -347,11 +347,6 @@ export class ReactLoopAgent extends Agent { agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause) } if (!keepInbox) { - // Whether the parked driver was already scheduled to run: a waking item - // woke `waitForQueued`, so the loop WILL resume and settle idle waiters - // itself through the pre-run-cancel path (possibly after a replacement - // prompt). Only a lone quiet item leaves the loop truly parked. - const willResume = this.#inbox.hasWakingQueued // Snapshot before clearing so the discard notification carries the exact // dropped items; a replacement synchronously enqueued by an // `agent/cancel-requested` observer belongs to the next turn, not here. @@ -362,15 +357,12 @@ export class ReactLoopAgent extends Agent { const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) } - // Clearing a parked quiet (`wakeup:false`) item reaches quiescence with no - // status transition and without waking the parked driver, so settle any - // `whenIdle` waiter here. When a waking item was present the loop resumes - // and settles itself; while `running` (including the post-turn flush - // window) the driver still owns the eventual idle transition. So settle - // only for a parked, non-running agent whose sole cleared work was quiet. - if (cancellation === undefined && !willResume && this._status !== 'running') { - this.settleIdleWaiters() - } + // No idle-waiter settle here: a `whenIdle` waiter exists only while the + // agent is `running` or a waking item is queued, and neither is left + // quiescent by clearing the inbox — a lone quiet item takes `whenIdle`'s + // fast path (no waiter), a waking item keeps the woken driver running, + // and a running agent owns its own idle transition (including the + // post-turn flush window). } cancellation?.request(resolvedCause) } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 86d7c106f2..4324deca8d 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -264,9 +264,17 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { handle.clearTurnCancellation(cancellation) } - // Late steering becomes queued input unless terminal policy stopped the turn. - for (const message of handle.inbox.drainSteering()) { - if (!terminalStopped) handle.inbox.enqueue(message) + // Late steering (arriving after runTurn returns, e.g. during the post-turn + // flush) becomes queued input — unless terminal policy stopped the turn, in + // which case it is dropped and must publish a discard so its enqueue is + // still matched (the invariant only catches a NEGATIVE count, not a leak). + const lateSteering = handle.inbox.drainSteering() + if (terminalStopped) { + if (lateSteering.length > 0) { + events.emit('agent/inbox/discard', lateSteering.map(message => agentMessage(message, true))) + } + } else { + for (const message of lateSteering) handle.inbox.enqueue(message) } // Park at idle unless a waking item still wants the model to run; a lone diff --git a/packages/core/agent-loop/tests/inbox-invariant.spec.ts b/packages/core/agent-loop/tests/inbox-invariant.spec.ts index 1d3e5b0360..b1634afdb9 100644 --- a/packages/core/agent-loop/tests/inbox-invariant.spec.ts +++ b/packages/core/agent-loop/tests/inbox-invariant.spec.ts @@ -117,4 +117,39 @@ describe('inbox FIFO-conservation invariant', () => { expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) }) + + it('stays balanced when late steering lands after a terminal stop (post-turn flush window)', async () => { + const adapter = new MockAdapter([textResponse('done')]) + const ctx = await harness(adapter) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + let enqueues = 0 + const discards: number[] = [] + ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent) enqueues += 1 }) + ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) }) + + // Terminal-stop the turn, then steer during the post-turn flush window + // (status is still running). That late steer is drained by runLoop and + // dropped because the turn terminally stopped; it must still be discarded so + // its enqueue is matched (the drain sits on a different code path than the + // in-turn terminal-stop drop). + ctx.on('agent/turn-stop', subject => (subject === agent ? { action: 'stop' as const } : undefined)) + let steered = false + ctx.on('session/flush', (session) => { + if (session !== agent.session || steered) return + steered = true + agent.steer([{ type: 'text', text: 'late' }], { source: { kind: 'plugin', plugin: 'late' } }) + }) + + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + // The prompt plus the late steer both enqueued; both are matched (the prompt + // dequeued, the late steer discarded) so no id is left outstanding. + expect(enqueues).toBe(2) + expect(discards).toEqual([1]) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) + }) }) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 9986affe6b..d542c39810 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -89,7 +89,9 @@ export function AgentMessageId(id: string): AgentMessageId { * message's enqueue, dequeue, and discard events. Source defaults are already * applied, so these are the exact values the item was accepted with. `steering` * is true for a `next-step` item drained between steps; a `next-turn` item is - * claimed at a turn boundary. + * claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is + * durable model-hidden state that lands on the eventual `user/message`/ + * `steering/message`, not live-event routing data. */ export interface AgentMessage { /** The id `send` returned for this message. */ diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index 38cba69427..9aedf69aca 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -36,20 +36,34 @@ function quote(value: string): string { * program against, so it belongs in the type closure alongside interfaces. */ function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration { - const members = node.members.map((member): ts.ClassElement => { + const isNonPublic = (member: ts.ClassElement): boolean => + (ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some(m => + m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false + const members = node.members.flatMap((member): ts.ClassElement[] => { + // A model-facing type shape carries only the public surface — drop private, + // protected, and #private members, and strip every kept member's body. + if (isNonPublic(member) || (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name))) return [] if (ts.isMethodDeclaration(member)) { - return ts.factory.updateMethodDeclaration( + return [ts.factory.updateMethodDeclaration( member, member.modifiers, member.asteriskToken, member.name, member.questionToken, - member.typeParameters, member.parameters, member.type, undefined) + member.typeParameters, member.parameters, member.type, undefined)] } if (ts.isConstructorDeclaration(member)) { - return ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined) + return [ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined)] + } + if (ts.isGetAccessorDeclaration(member)) { + return [ts.factory.updateGetAccessorDeclaration( + member, member.modifiers, member.name, member.parameters, member.type, undefined)] + } + if (ts.isSetAccessorDeclaration(member)) { + return [ts.factory.updateSetAccessorDeclaration( + member, member.modifiers, member.name, member.parameters, undefined)] } if (ts.isPropertyDeclaration(member)) { - return ts.factory.updatePropertyDeclaration( - member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined) + return [ts.factory.updatePropertyDeclaration( + member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined)] } - return member + return [member] }) return ts.factory.updateClassDeclaration( node, node.modifiers, node.name, node.typeParameters, node.heritageClauses, members) From 7b7f793ee574026113f8f30c0f5c301af9557096 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 23 Jul 2026 22:57:39 +0800 Subject: [PATCH 09/37] test: fix CI coverage + e2e for user/message coalescing - tui.spec: exercise a goal-sourced injected context card (labels by source kind, not plugin name), closing the last uncovered branch in tui/src/index.ts that the CI coverage gate caught. - time-context.e2e / goal.e2e: filter injected context by source now that it is a user/message (plugin/goal source), and count goal continuation rounds by round>0 rather than event type. --- packages/context/time-context/tests/time-context.e2e.ts | 3 ++- packages/goal/goal/tests/goal.e2e.ts | 4 +++- packages/ui/tui/tests/tui.spec.ts | 4 ++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index 13edcfb036..02704d3eba 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -48,7 +48,8 @@ describe('time-context through a real headless cordis.yml', () => { expect(stderr).not.toContain('UNHANDLED') expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) - const contexts = events.filter(event => event.type === 'user/message') + const contexts = events.filter( + (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin') const starts = events.filter(event => event.type === 'step/start') expect(contexts).toHaveLength(2) expect(starts).toHaveLength(2) diff --git a/packages/goal/goal/tests/goal.e2e.ts b/packages/goal/goal/tests/goal.e2e.ts index 357bebe227..756073083a 100644 --- a/packages/goal/goal/tests/goal.e2e.ts +++ b/packages/goal/goal/tests/goal.e2e.ts @@ -69,7 +69,9 @@ describe('goal domain through a real cordis.yml and headless process', () => { }) expect(context.data.content).toEqual(renderGoalChange(change)) expect(JSON.stringify(context)).not.toContain('activation') + // No admitted continuation round ran (the snapshot mounts without starting + // a round); the round-zero state change from create is expected above. expect(events.filter(event => event.type === 'user/message' - && event.data.source.kind === 'goal')).toHaveLength(0) + && event.data.source.kind === 'goal' && event.data.source.round > 0)).toHaveLength(0) }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 9d6a2bed4f..f5b4763ef6 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -395,6 +395,9 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) + // A non-plugin injected source (goal) has no `plugin` field, so its context + // card label falls back to the source kind. + result.session.append('user/message', { content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0 } as never }, { surfaceOp: 'append' }) result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' }) appendAssistant(result.session, []) result.session.append('step/end', { turn: 1, step: 1 }) @@ -475,6 +478,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Enter sends steering, Esc cancels') expect(result.terminal.output).toContain('Steering') expect(result.terminal.output).toContain('user context') + expect(result.terminal.output).toContain('Context · goal') // goal-sourced injected context labels by kind expect(result.terminal.output).toContain('Prompt blocked') expect(result.terminal.output).toContain('Turn cancelled') expect(result.terminal.progress).toContain(true) From b9f8eca10cad0e8ecc4936a31d7d63064918cdc8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 24 Jul 2026 11:23:16 +0800 Subject: [PATCH 10/37] fix(python-sdk): track recursive subagent notifications --- ...python-sdk-session-notifications.i18n.yaml | 6 ++ ...ursive-python-sdk-session-notifications.md | 27 ++++++ ...ive-python-sdk-session-notifications.zh.md | 27 ++++++ python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 4 +- python/sdk/README.zh.md | 2 +- python/sdk/src/deepseek_harness/api.py | 5 +- python/sdk/src/deepseek_harness/client.py | 64 +++++++++++--- python/sdk/tests/test_client.py | 86 +++++++++++++++++++ 9 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml new file mode 100644 index 0000000000..0e8626c838 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-recursive-python-sdk-session-notifications.md: c608cfa584e568602d599c7e18ffc36fabbd0186 +2026-07-24-recursive-python-sdk-session-notifications.zh.md: 397669cc13a5c77dabce55209be3cd2f0c7a82ea diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md new file mode 100644 index 0000000000..c608cfa584 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md @@ -0,0 +1,27 @@ +# Agent Note: Recursive Python SDK session notifications + +Status: implemented + +English | [中文](2026-07-24-recursive-python-sdk-session-notifications.zh.md) + +## Problem + +The Python SDK filtered turn notifications by comparing each payload directly with the root session id. This admitted a direct child's lifecycle because its parent id named the root, but rejected a grandchild's lifecycle and every descendant `session.event`. The JSON-RPC server still emitted those notifications, so they accumulated on the low-level global queue while high-level consumers lost nested trajectory relationships and completion states. + +## Decision + +`HarnessClient` records every valid `subagent.started` and `subagent.finished` child-to-parent edge before dispatching the notification. Session subscriptions classify each payload session id, parent id, and child id by walking that client-lifetime ancestry graph to their requested root. The graph survives successive subscriptions so a descendant that outlives one `Session.run()` remains attributable when it emits during a later turn, and it resets when the client starts a new runtime process. + +`Session.run()` delivers the complete discovered session-tree notification stream through `TurnResult.notifications` and `on_notification`. Only `session.event` notifications whose `sessionId` equals the requested root enter `TurnResult.events` or final-response reconstruction. Descendant events are therefore observable without allowing a child response to replace the root response. + +## Alternatives considered + +**Add a root session id to every JSON-RPC notification.** The server already provides exact immediate-parent edges, and duplicating transitive ancestry on the wire would make every producer responsible for client subscription state. + +**Limit subagents to one level.** A deployment can set `maxDepth: 1`, but changing the SDK to depend on that policy would silently misreport valid recursive compositions. + +**Subscribe only to descendant lifecycle notifications.** This would repair relation and completion reporting, but descendant session events would continue accumulating on the global queue and callbacks would expose an incomplete tree. + +## Consequences + +High-level consumers receive nested lifecycle and session notifications in wire order while root turn results preserve their prior response semantics. The client retains one parent entry per observed child until the runtime restarts; ancestry lookup is cycle-safe, and unrelated session notifications remain available through the global queue. Keyless Python tests cover two-level delegation, root-response isolation, absence of tree-notification queue buildup, and ancestry reuse across subscriptions. diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md new file mode 100644 index 0000000000..397669cc13 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Python SDK 递归会话通知 + +Status: implemented + +[English](2026-07-24-recursive-python-sdk-session-notifications.md) | 中文 + +## 问题 + +Python SDK 过去通过将每条通知的 payload 与根会话 ID 直接比较来过滤轮次通知。直接子 agent 的生命周期通知因 parent ID 指向根会话而能够通过,但孙级生命周期通知与所有后代 `session.event` 都会被拒绝。JSON-RPC 服务器仍会发出这些通知,因此它们会堆积在底层全局队列中,而高层消费者会丢失嵌套轨迹的关系与结束状态。 + +## 决策 + +`HarnessClient` 会在分发通知前,记录每条有效 `subagent.started` 和 `subagent.finished` 所包含的 child-to-parent(子到父)关系。会话订阅会沿客户端生命周期内保存的祖先关系图回溯每个 payload 中的 session ID、parent ID 与 child ID,判断它们是否属于请求的根会话。该关系图会跨连续订阅保留,因此某个后代即使跨过一次 `Session.run()`,在后续轮次中发出通知时仍能正确归属;客户端启动新的运行时进程时会重置关系图。 + +`Session.run()` 通过 `TurnResult.notifications` 与 `on_notification` 提供已发现会话树的完整通知流。只有 `sessionId` 等于请求根会话的 `session.event` 才会进入 `TurnResult.events` 或参与最终回复重建。因此调用方能够观察后代事件,同时子会话回复不会覆盖根会话回复。 + +## 考虑过的替代方案 + +**在每条 JSON-RPC 通知中加入根会话 ID。** 服务器已经提供精确的直接父子关系;在线路协议中重复传递祖先关系,会迫使每个生产者承担客户端订阅状态的职责。 + +**把 subagent 限制为一层。** 部署可以设置 `maxDepth: 1`,但让 SDK 依赖该策略,会对合法的递归组合产生静默误报。 + +**只订阅后代生命周期通知。** 这可以修复关系与结束状态的上报,但后代会话事件仍会堆积在全局队列中,回调看到的会话树也不完整。 + +## 后果 + +高层消费者会按线上的原始顺序收到嵌套生命周期与会话通知,同时根轮次结果保持原有回复语义。客户端会为每个已观察到的子会话保留一条父关系,直到运行时重启;祖先回溯能够安全处理环,无关会话通知仍可从全局队列获取。无密钥 Python 测试覆盖两层派生、根回复隔离、会话树通知不堆积,以及跨订阅复用祖先关系。 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 956d6f8ff8..ed74d60087 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 23d15d617b3d295a6cc2d8d20c6d03abc226834b -README.zh.md: 4f6aef13833af937babc2e5a92bfd14c12170534 +README.md: bfa31a712acd6fccf1458a0a80fc2ff80dfe114e +README.zh.md: 11ebcdd133b2fd839b73f50ef2be2e531e8bbc2a diff --git a/python/sdk/README.md b/python/sdk/README.md index 23d15d617b..bfa31a712a 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -34,9 +34,7 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. The bundled default composition registers `deepseek`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. -`TurnResult.final_response` is the text content from the last -`assistant/message` event in the turn. Use `TurnResult.events` for the complete -event stream, including intermediate assistant messages and tool activity. +`HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `TurnResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `TurnResult.events` remains the root session's complete event stream, and `TurnResult.final_response` is the text content from its last `assistant/message`; descendant messages therefore cannot replace the root response. The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 4f6aef1383..11ebcdd133 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -30,7 +30,7 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。内置默认组合注册 `deepseek`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 -`TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整的事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取。 +`HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`TurnResult.notifications` 与 `on_notification` 会按线上的原始顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期与会话事件。`TurnResult.events` 仍只保存根会话的完整事件流,`TurnResult.final_response` 则取该会话最后一个 `assistant/message` 的文本内容,因此后代消息不会覆盖根会话回复。 同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin`、`bridge_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 2b44a50c64..d96e974bc3 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -143,7 +143,10 @@ class Session: notifications.append(notification) if on_notification is not None: on_notification(notification) - if notification.method == "session.event": + if ( + notification.method == "session.event" + and notification.payload.get("sessionId") == self.id + ): event = notification.payload.get("event") if isinstance(event, dict): events.append(event) diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index e552c8b685..b80d83b5f4 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -47,6 +47,7 @@ class HarnessClient: self._notification_subscribers: dict[ str, tuple[queue.Queue[Notification | BaseException], NotificationFilter | None] ] = {} + self._session_parents: dict[str, str] = {} self._requests: queue.Queue[IncomingRequest | BaseException] = queue.Queue() self._stderr_lines: deque[str] = deque(maxlen=400) self._reader_thread: threading.Thread | None = None @@ -62,6 +63,8 @@ class HarnessClient: def start(self) -> None: if self._proc is not None: return + with self._lock: + self._session_parents.clear() args = list(self.config.launch_args_override or self._default_launch_args()) env = os.environ.copy() if self.config.env: @@ -143,7 +146,7 @@ class HarnessClient: payload, response_model=_SessionPromptResponse, on_notification=on_notification, - notification_filter=_notification_belongs_to_session(session_id), + notification_filter=self._notification_belongs_to_session_tree(session_id), notification_subscription=notification_subscription, ) @@ -193,7 +196,8 @@ class HarnessClient: return NotificationSubscription(self, subscription_id, notifications) def subscribe_session_notifications(self, session_id: str) -> "NotificationSubscription": - return self.subscribe_notifications(_notification_belongs_to_session(session_id)) + """Subscribe to a session and descendants discovered from subagent lifecycle edges.""" + return self.subscribe_notifications(self._notification_belongs_to_session_tree(session_id)) def next_request(self) -> IncomingRequest: item = self._requests.get() @@ -352,6 +356,7 @@ class HarnessClient: params = message.get("params") notification = Notification(method=method, payload=params if isinstance(params, dict) else {}) with self._lock: + self._record_session_relationship_locked(notification) subscribers = list(self._notification_subscribers.items()) delivered = False for subscription_id, (subscriber, predicate) in subscribers: @@ -439,6 +444,49 @@ class HarnessClient: with self._lock: self._notification_subscribers.pop(subscription_id, None) + def _record_session_relationship_locked(self, notification: Notification) -> None: + if notification.method not in {"subagent.started", "subagent.finished"}: + return + parent_id = notification.payload.get("parentSessionId") + child_id = notification.payload.get("childSessionId") + if ( + isinstance(parent_id, str) + and parent_id + and isinstance(child_id, str) + and child_id + and parent_id != child_id + ): + self._session_parents[child_id] = parent_id + + def _notification_belongs_to_session_tree(self, session_id: str) -> NotificationFilter: + def belongs(notification: Notification) -> bool: + payload = notification.payload + related_ids = ( + payload.get("sessionId"), + payload.get("parentSessionId"), + payload.get("childSessionId"), + ) + return any( + isinstance(related_id, str) + and self._session_is_descendant_of(related_id, session_id) + for related_id in related_ids + ) + + return belongs + + def _session_is_descendant_of(self, session_id: str, root_session_id: str) -> bool: + current = session_id + visited: set[str] = set() + while current not in visited: + if current == root_session_id: + return True + visited.add(current) + parent = self._session_parents.get(current) + if parent is None: + return False + current = parent + return False + class NotificationSubscription: def __init__( @@ -491,15 +539,3 @@ class _ShutdownResponse(BaseModel): def _int_or_none(value: object) -> int | None: return value if isinstance(value, int) else None - - -def _notification_belongs_to_session(session_id: str) -> NotificationFilter: - def belongs(notification: Notification) -> bool: - payload = notification.payload - return ( - payload.get("sessionId") == session_id - or payload.get("parentSessionId") == session_id - or payload.get("childSessionId") == session_id - ) - - return belongs diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index f66abf4b36..fda0686980 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -198,6 +198,65 @@ for line in sys.stdin: ] +def test_session_run_collects_nested_subagent_tree_without_polluting_root_events( + tmp_path: Path, +) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + root = (msg.get("params") or {})["sessionId"] + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": root, "childSessionId": "child"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "child", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "child response"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "child", "childSessionId": "grandchild"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "grandchild", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "grandchild response"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": "child", "childSessionId": "grandchild", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": root, "childSessionId": "child", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": root, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "root response"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": root, "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + seen: list[str] = [] + with DeepSeekHarness( + launch_args_override=(sys.executable, str(script)), + cwd=str(tmp_path), + ) as harness: + result = harness.run( + "delegate recursively", + session_id="main", + on_notification=lambda notification: seen.append(notification.method), + ) + assert harness.client._notifications.qsize() == 0 + + assert result.status == "ok" + assert result.final_response == "root response" + assert [event["data"]["content"][0]["text"] for event in result.events] == ["root response"] + assert [notification.method for notification in result.notifications] == [ + "subagent.started", + "session.event", + "subagent.started", + "session.event", + "subagent.finished", + "subagent.finished", + "session.event", + "session.finished", + ] + assert seen == [notification.method for notification in result.notifications] + + def test_session_run_ignores_notifications_for_other_sessions(tmp_path: Path) -> None: script = tmp_path / "fake_runtime.py" script.write_text( @@ -356,6 +415,33 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe assert notification.payload["sessionId"] == "other" +def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None: + client = HarnessClient() + with client.subscribe_session_notifications("main") as first: + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.started", + "params": {"parentSessionId": "main", "childSessionId": "child"}, + }) + assert first.next().payload["childSessionId"] == "child" + + with client.subscribe_session_notifications("main") as second: + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.started", + "params": {"parentSessionId": "child", "childSessionId": "grandchild"}, + }) + client._handle_message({ + "jsonrpc": "2.0", + "method": "session.event", + "params": {"sessionId": "grandchild", "event": {"type": "assistant/message"}}, + }) + assert second.next().payload["childSessionId"] == "grandchild" + assert second.next().payload["sessionId"] == "grandchild" + + assert client._notifications.qsize() == 0 + + def test_client_contains_notification_filter_failure_to_its_subscription(tmp_path: Path) -> None: script = tmp_path / "fake_bridge.py" script.write_text( From c7c1b97501e7200f28f78fd8bedc850b15e158c4 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 12:05:57 +0800 Subject: [PATCH 11/37] =?UTF-8?q?fix(agent-loop):=20address=20second-round?= =?UTF-8?q?=20review=20=E2=80=94=20disposal=20discard,=20injection=20valid?= =?UTF-8?q?ation,=20frozen=20payloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the review bot's five genuinely-new findings on the current code: - disposal now discards any still-pending inbox items before the loop exits, so every enqueued id gets a terminal lifecycle event. - injection (next-step/no-wakeup) validates its payload up front, before opening the idle one-shot turn, honoring 'invalid input throws before any append'; and rejects attached contexts (which belong only to inbox messages) rather than silently dropping them. - agentMessage() freezes the agent/inbox/* payload so a listener cannot mutate the shared correlation object mid-dispatch. - refresh the package READMEs (compact, goal, guard, hook-protocol, plan-mode, time-context, workspace-context) that still referenced the removed context/message event, with the source-based user/message distinction. The up-front injection validation makes two finally branches unreachable (v8-ignored as the turn-enclosure backstop). Adds regression tests for disposal discard, context rejection, up-front validation, and the frozen payload; per-file coverage stays 100%. --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 2 +- ...ied-send-and-coalesced-user-messages.zh.md | 2 +- packages/compact/compact/README.md | 2 +- packages/context/time-context/README.md | 4 +- packages/context/workspace-context/README.md | 8 +- packages/core/agent-loop/src/agent.ts | 74 +++++++++++-------- packages/core/agent-loop/src/inbox.ts | 9 ++- packages/core/agent-loop/tests/agent.spec.ts | 63 ++++++++++++---- packages/core/agent-loop/tests/inbox.spec.ts | 11 ++- packages/goal/goal/README.md | 2 +- packages/guard/repeat-tool-guard/README.md | 4 +- packages/hooks/hook-protocol/README.md | 2 +- packages/plan/plan-mode/README.md | 2 +- 14 files changed, 127 insertions(+), 62 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 7c3ed96243..e0e804e953 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: 9eb355128b217a0ea8dc09daf4e83334f6aeaa10 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 679c9100aa49777b7b601725bdfc077f5f2dab0e +2026-07-22-unified-send-and-coalesced-user-messages.md: eaef3d7e8efd8362c5029d777701c5db59c1ee5b +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: f84fe22f457677b8f8463417b2931cfd0ec77508 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 9eb355128b..eaef3d7e8e 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -36,7 +36,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. -`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it — both at the in-turn stop point and on the post-turn drain of late steering — and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. +`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every enqueued id gets exactly one terminal lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` (both at the in-turn stop point and on the post-turn drain of late steering), and disposal discards any still-pending items before the loop exits. The `agent/inbox/*` payload is frozen so a listener cannot mutate the shared correlation object mid-dispatch, and a loop-authored continuation reason is snapshotted and frozen like a public send. Injection (`next-step`/no-wakeup) validates its payload up front — before opening the idle one-shot turn, honoring the "invalid input throws before any append" contract — and rejects attached `contexts` (which belong only to inbox messages) rather than silently dropping them. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 679c9100aa..f84fe22f45 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -36,7 +36,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` 投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`——既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时——而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每个已入队的 id 都恰好得到一个终止性生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`(既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时),而 dispose(资源释放)会在 loop 退出前丢弃所有仍在等待的项。`agent/inbox/*` 的事件载荷已被冻结,因此监听器无法在分发中途修改共享的关联对象,而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。注入(`next-step`/no-wakeup)会预先校验其载荷——在打开空闲状态的一次性轮次之前,从而遵守“无效输入在任何追加之前抛出”的契约——并拒绝附带的 `contexts`(`contexts` 只属于 inbox 消息),而不是静默丢弃它们。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 ## 相关 diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 03b91d653e..ff47ef3b19 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -33,7 +33,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an ## Surface contract -`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead: +`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead: 1. appends `compact/start` (log-only) — acquires the lock, 2. summarizes the range, diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index 2327da81b2..f4938982a1 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -18,9 +18,9 @@ When `timeZone` is omitted, the plugin resolves the Node process's system zone o ## Timing semantics -The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one `context/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing. +The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing. -Positive-interval scheduling scans the raw durable session events for the latest `context/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently. +Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently. Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`. diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index a7245df91f..67ab9b4245 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -28,7 +28,7 @@ Instructions from: AGENTS.md ``` -Newly reached scopes use a durable raw `context/message`: +Newly reached scopes use a durable injected `user/message` (plugin source): ```md @@ -42,11 +42,11 @@ These instructions apply to work under `packages/app`. Use them as guidance when A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text inside an instruction file is escaped so file content cannot close the plugin-owned frame. -The plugin owns the complete `` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping. +The plugin owns the complete `` framing, and every injected `user/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping. ## State And Refresh -Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. +Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only. @@ -111,7 +111,7 @@ Prefix-stable within one loop instance because the baseline is frozen. A new or #### What the model sees -After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file. +After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained injected `user/message` with the newly applicable instruction file. ##### Additional instruction template diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index baa2e3f08d..9bc24de5da 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -248,14 +248,22 @@ export class ReactLoopAgent extends Agent { /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ private injectContext(content: ContentBlock[], options?: SendOptions): void { + // Injection is synthetic durable context, not an inbox message: attached + // contexts belong only to queued/steering sends, so reject them rather than + // silently dropping a value the option type structurally permits. + if (options?.contexts !== undefined && options.contexts.length > 0) { + throw new TypeError('agent inject (next-step/no-wakeup) does not accept attached contexts') + } const source = options?.source ?? { kind: 'plugin', plugin: '' } - const context = { + // Detach and validate the payload BEFORE any append, so malformed input + // throws without opening a one-shot turn or mutating the session (the + // unified send contract: invalid input throws before any append). + const accepted = this.acceptContext({ content, source, ...options?.meta !== undefined ? { meta: options.meta } : {}, - } + }) if (isTurnOpen(this.session)) { - const accepted = this.acceptContext(context) // Provider protocols require every assistant tool-call batch to be // followed only by its tool results. Historical interrupted batches do // not own new context; only the currently executing batch may defer it. @@ -267,39 +275,35 @@ export class ReactLoopAgent extends Agent { return } // No turn open: wrap the injection in a one-shot turn so every event stays - // turn-enclosed (the durability/replay boundary is the turn). + // turn-enclosed (the durability/replay boundary is the turn). The payload is + // validated above, so both appends commit together; the finally still owes + // a turn/end (the turn-enclosure invariant) even if a post-commit observer + // throws after turn/start. const turn = lastTurnNumber(this.session) + 1 - // Once turn/start enters the log, a turn/end is owed even if the message - // append fails acceptance or pre-commit validation. The finally re-checks - // the log and closes only a turn that actually opened; post-commit observers - // are contained by Session and cannot create a false append failure. try { this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - this.session.append('user/message', context, { surfaceOp: 'append' }) + this.session.append('user/message', accepted, { surfaceOp: 'append' }) } finally { - // Close the turn if turn/start made it into the log. A pre-commit veto - // must escape rather than being mistaken for a committed turn/end. + // Close the turn if turn/start committed. With the payload validated up + // front both appends commit together, so the turn is always open here; + // the guard remains the turn-enclosure backstop. + /* v8 ignore next -- unopened turn is unreachable after up-front validation; kept as the enclosure backstop. */ if (isTurnOpen(this.session)) { this.session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - // Decide the durability checkpoint from the log: an accepted one-shot - // turn must be flushed even when its message append was the failing step. - const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) - // Keep inject() synchronous: report checkpoint failures live instead of - // rejecting the caller, and track the task so disposal still drains it. - if (turnRecorded) { - // Through the store's flush (the carrier owner), never a raw parallel. - const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { - const rendered = errorChain(error) - const err = error instanceof Error ? error : new Error(rendered) - this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) - agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) - }) - this.pendingIdleFlushes.add(flush) - // Retire on either settlement path. - const retire = (): void => { this.pendingIdleFlushes.delete(flush) } - void flush.then(retire, retire) - } + // Flush the one-shot turn through the store (the carrier owner), never a + // raw parallel. Keep inject() synchronous: report checkpoint failures live + // instead of rejecting the caller, and track the task so disposal drains it. + const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { + const rendered = errorChain(error) + const err = error instanceof Error ? error : new Error(rendered) + this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) + agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) + }) + this.pendingIdleFlushes.add(flush) + // Retire on either settlement path. + const retire = (): void => { this.pendingIdleFlushes.delete(flush) } + void flush.then(retire, retire) } } @@ -434,6 +438,18 @@ export class ReactLoopAgent extends Agent { */ private [stopDriver](): Promise | void { if (this._status !== 'disposed') { + // Discard any still-pending inbox items before disposal so every enqueued + // id gets a terminal lifecycle event; a disposed agent never dequeues + // them. Emitted while still published (before the status flip below), and + // only when there is a public lifecycle to observe it. + if (this.published) { + const discarded = this.#inbox.pending() + if (discarded.length > 0) { + const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) + } + } + this.#inbox.clear() this._status = 'disposed' this.resolveDisposed() // Release whenIdle waiters BEFORE the (guarded) event emit — they are diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index 3cb82944e4..c0a6f4d694 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -29,7 +29,14 @@ export interface InboxMessage { * @returns the live-event message for enqueue/dequeue/discard. */ export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage { - return { id: message.id, content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } + // Frozen: the fused emitter passes this exact object to every listener in + // turn, so one listener must not be able to mutate a field (`id`, `steering`, + // `content`, …) a later listener then observes. `message` is already a frozen + // inbox record, so its nested fields need no re-clone. + return Object.freeze({ + id: message.id, content: message.content, source: message.source, + contexts: message.contexts, steering, wakeup: message.wakeup, + }) } /** diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 608f1bad60..3b8942d27c 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -98,6 +98,27 @@ describe('Agent', () => { expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) + it('disposal discards still-pending inbox items so every id gets a terminal event', async () => { + const adapter = new MockAdapter(['hang']) + const ctx = await harness(adapter) + let agent!: Agent + const discarded: string[] = [] + ctx.on('agent/inbox/discard', (subject, messages) => { + if (subject === agent) discarded.push(...messages.map(m => m.id)) + }) + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) + }, { inject: ['agentLoop'] })) + + // A quiet (non-waking) item stays parked in the inbox; disposal must drop it + // WITH a discard so its enqueued id is not left dangling forever. + const id = agent.send([{ type: 'text', text: 'never runs' }], { target: 'next-turn', wakeup: false }) + await fiber.dispose() + await driverDone(agent) + + expect(discarded).toEqual([id]) + }) + it('steer() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) @@ -177,24 +198,22 @@ describe('Agent', () => { warn.mockRestore() }) - it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => { + it('idle inject() validates its payload BEFORE opening a turn, so invalid input appends nothing', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) - // Non-serializable injected content makes Session.append throw AFTER - // turn/start was recorded. The turn/end must still be appended (finally), - // AND the durability checkpoint must still fire — the balanced turn is in - // memory and a crash before the next turn/dispose would otherwise lose it. + // Non-serializable injected content is rejected by the up-front snapshot + // BEFORE any append (the unified send contract: invalid input throws before + // mutating the log). No one-shot turn opens and no durability checkpoint fires. expect(() => { agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } }) - }).toThrow(/non-JSON-serializable/) - const types = agent.session.events.map(e => e.type) - expect(types).toEqual(['turn/start', 'turn/end']) // balanced, no open turn - await new Promise(r => setTimeout(r, 10)) // let the fire-and-forget flush run - expect(flushes).toBe(1) // checkpoint fired despite the throw + }).toThrow(/losslessly JSON-serializable/) + expect(agent.session.events).toHaveLength(0) + await new Promise(r => setTimeout(r, 10)) // give any (erroneous) flush a chance + expect(flushes).toBe(0) // nothing was appended, so no checkpoint }) it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => { @@ -244,13 +263,27 @@ describe('Agent', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - // A non-serializable source makes the turn/start append throw BEFORE the - // event is pushed (Session.append validates before push), so NO turn opens. - // The finally's isTurnOpen() guard sees no open turn and appends nothing — - // the log stays empty, not left with a dangling turn/start. + // A non-serializable source is rejected by the up-front snapshot BEFORE any + // append, so NO turn opens and the log stays empty. expect(() => { agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never }) - }).toThrow(/non-JSON-serializable/) + }).toThrow(/losslessly JSON-serializable/) + expect(agent.session.events).toHaveLength(0) + }) + + it('inject() rejects attached contexts (they belong to inbox messages, not injection)', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + // contexts structurally compile on AliasSendOptions but injection cannot + // carry them, so they are rejected rather than silently dropped. + expect(() => { + agent.inject([{ type: 'text', text: 'x' }], { + source: { kind: 'plugin', plugin: 'p' }, + contexts: [{ content: [{ type: 'text', text: 'ctx' }], source: { kind: 'plugin', plugin: 'p' } }], + } as never) + }).toThrow(/does not accept attached contexts/) expect(agent.session.events).toHaveLength(0) }) diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 791eae3bda..289cd2f6d5 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -1,11 +1,20 @@ import { describe, expect, it } from 'vitest' import { AgentMessageId } from '@deepseek-ai/dsh-agent' -import { Inbox } from '../src/inbox.ts' +import { Inbox, agentMessage } from '../src/inbox.ts' function message(text: string) { return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true } } +describe('agentMessage', () => { + it('returns a frozen payload so a listener cannot mutate it for later listeners', () => { + const payload = agentMessage(message('m'), false) + expect(Object.isFrozen(payload)).toBe(true) + expect(() => { (payload as { id: string }).id = 'mutated' }).toThrow() + expect(payload.id).toBe(AgentMessageId('m')) + }) +}) + function resolverPair() { let r!: () => void const p = new Promise((resolve) => { r = resolve }) diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index e50044d135..1df6d08f5c 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -19,7 +19,7 @@ Event-sourced same-session goal state. The service retains one current completio At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation. -Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The `context/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. +Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The round-zero `user/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index ef5e4e5846..e7bd79732e 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -30,11 +30,11 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de ## Reminder delivery -Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as a plain synthetic user message — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata. +Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as an injected `user/message` after the step's tool results, which the session renders as a plain synthetic user message — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata. ## Testing -Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as `context/message`s in the ACP transcript. +Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as injected `user/message`s in the ACP transcript. ## Model Experience diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 42a642caf0..3a84807285 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -27,7 +27,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty). -Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks Agent Note. +Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `user/message` is the durable evidence) — see the hooks Agent Note. ## Model Experience diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 2725954185..0f24508d14 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -6,7 +6,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc `plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`. -`ctx.planMode.set(agent, active)` records a pending selection and flushes it inside the next turn boundary. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's optimistic selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one `context/message` notice when the last logged request header described the other state. +`ctx.planMode.set(agent, active)` records a pending selection and flushes it inside the next turn boundary. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's optimistic selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state. ## Model and human surfaces From 11eedca16e0ecb9e6cc53865965478ac9d74bcfd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 24 Jul 2026 12:21:10 +0800 Subject: [PATCH 12/37] fix(python-sdk): preserve reused session ancestry --- ...python-sdk-session-notifications.i18n.yaml | 4 +- ...ursive-python-sdk-session-notifications.md | 6 +- ...ive-python-sdk-session-notifications.zh.md | 6 +- python/sdk/src/deepseek_harness/client.py | 19 +++--- python/sdk/tests/test_client.py | 61 ++++++++++++++++++- 5 files changed, 81 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml index 0e8626c838..cae5b75cb4 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-recursive-python-sdk-session-notifications.md: c608cfa584e568602d599c7e18ffc36fabbd0186 -2026-07-24-recursive-python-sdk-session-notifications.zh.md: 397669cc13a5c77dabce55209be3cd2f0c7a82ea +2026-07-24-recursive-python-sdk-session-notifications.md: c90213659391b565acd043a1be64e225f8babd31 +2026-07-24-recursive-python-sdk-session-notifications.zh.md: 214a5ef924dcc9da3a97aab6385837acd2b364d9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md index c608cfa584..c902136593 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md @@ -10,7 +10,7 @@ The Python SDK filtered turn notifications by comparing each payload directly wi ## Decision -`HarnessClient` records every valid `subagent.started` and `subagent.finished` child-to-parent edge before dispatching the notification. Session subscriptions classify each payload session id, parent id, and child id by walking that client-lifetime ancestry graph to their requested root. The graph survives successive subscriptions so a descendant that outlives one `Session.run()` remains attributable when it emits during a later turn, and it resets when the client starts a new runtime process. +`HarnessClient` records every valid `subagent.started` child-to-parent edge before dispatching the notification. A later `subagent.finished` routes by its immutable parent id but never rewrites current ancestry, so an older run that settles after its child id has been reused cannot displace the replacement session. Other session notifications resolve their session id by walking that client-lifetime ancestry graph to the requested root. The graph survives successive subscriptions so a descendant that outlives one `Session.run()` remains attributable when it emits during a later turn, and it resets when the client starts a new runtime process. `Session.run()` delivers the complete discovered session-tree notification stream through `TurnResult.notifications` and `on_notification`. Only `session.event` notifications whose `sessionId` equals the requested root enter `TurnResult.events` or final-response reconstruction. Descendant events are therefore observable without allowing a child response to replace the root response. @@ -22,6 +22,8 @@ The Python SDK filtered turn notifications by comparing each payload directly wi **Subscribe only to descendant lifecycle notifications.** This would repair relation and completion reporting, but descendant session events would continue accumulating on the global queue and callbacks would expose an incomplete tree. +**Expose and index every subagent run id on the JSON-RPC wire.** Exact run identity is useful when a client must correlate two concurrent outcomes for the same child, but session-tree routing already has the authoritative start edge and each terminal notification's immutable parent. Expanding the protocol is unnecessary for this ownership decision. + ## Consequences -High-level consumers receive nested lifecycle and session notifications in wire order while root turn results preserve their prior response semantics. The client retains one parent entry per observed child until the runtime restarts; ancestry lookup is cycle-safe, and unrelated session notifications remain available through the global queue. Keyless Python tests cover two-level delegation, root-response isolation, absence of tree-notification queue buildup, and ancestry reuse across subscriptions. +High-level consumers receive nested lifecycle and session notifications in wire order while root turn results preserve their prior response semantics. The client retains one current parent entry per observed child until the runtime restarts; ancestry lookup is cycle-safe, and unrelated session notifications remain available through the global queue. Keyless Python tests cover two-level delegation, root-response isolation, absence of tree-notification queue buildup, ancestry reuse across subscriptions, and reused child ids whose older runs settle out of order. diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md index 397669cc13..214a5ef924 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md @@ -10,7 +10,7 @@ Python SDK 过去通过将每条通知的 payload 与根会话 ID 直接比较 ## 决策 -`HarnessClient` 会在分发通知前,记录每条有效 `subagent.started` 和 `subagent.finished` 所包含的 child-to-parent(子到父)关系。会话订阅会沿客户端生命周期内保存的祖先关系图回溯每个 payload 中的 session ID、parent ID 与 child ID,判断它们是否属于请求的根会话。该关系图会跨连续订阅保留,因此某个后代即使跨过一次 `Session.run()`,在后续轮次中发出通知时仍能正确归属;客户端启动新的运行时进程时会重置关系图。 +`HarnessClient` 会在分发通知前,记录每条有效 `subagent.started` 所包含的 child-to-parent(子到父)关系。后续的 `subagent.finished` 会依据自身不可变的 parent ID 路由,但不会改写当前祖先关系,因此旧 run 即使在其 child ID 已被复用后才结束,也无法覆盖替代它的新会话。其他会话通知会沿客户端生命周期内保存的祖先关系图回溯自身 session ID,判断它们是否属于请求的根会话。该关系图会跨连续订阅保留,因此某个后代即使跨过一次 `Session.run()`,在后续轮次中发出通知时仍能正确归属;客户端启动新的运行时进程时会重置关系图。 `Session.run()` 通过 `TurnResult.notifications` 与 `on_notification` 提供已发现会话树的完整通知流。只有 `sessionId` 等于请求根会话的 `session.event` 才会进入 `TurnResult.events` 或参与最终回复重建。因此调用方能够观察后代事件,同时子会话回复不会覆盖根会话回复。 @@ -22,6 +22,8 @@ Python SDK 过去通过将每条通知的 payload 与根会话 ID 直接比较 **只订阅后代生命周期通知。** 这可以修复关系与结束状态的上报,但后代会话事件仍会堆积在全局队列中,回调看到的会话树也不完整。 +**在 JSON-RPC 线路上公开并索引每个 subagent run ID。** 当客户端必须关联同一 child 的两个并发结果时,精确 run 身份很有价值;但会话树路由已经拥有权威 start 关系和每条终止通知中不可变的 parent。没有必要为这一归属决策扩展协议。 + ## 后果 -高层消费者会按线上的原始顺序收到嵌套生命周期与会话通知,同时根轮次结果保持原有回复语义。客户端会为每个已观察到的子会话保留一条父关系,直到运行时重启;祖先回溯能够安全处理环,无关会话通知仍可从全局队列获取。无密钥 Python 测试覆盖两层派生、根回复隔离、会话树通知不堆积,以及跨订阅复用祖先关系。 +高层消费者会按线上的原始顺序收到嵌套生命周期与会话通知,同时根轮次结果保持原有回复语义。客户端会为每个已观察到的子会话保留一条当前父关系,直到运行时重启;祖先回溯能够安全处理环,无关会话通知仍可从全局队列获取。无密钥 Python 测试覆盖两层派生、根回复隔离、会话树通知不堆积、跨订阅复用祖先关系,以及旧 run 乱序结束的复用 child ID。 diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index b80d83b5f4..8d4ec7f848 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -445,7 +445,7 @@ class HarnessClient: self._notification_subscribers.pop(subscription_id, None) def _record_session_relationship_locked(self, notification: Notification) -> None: - if notification.method not in {"subagent.started", "subagent.finished"}: + if notification.method != "subagent.started": return parent_id = notification.payload.get("parentSessionId") child_id = notification.payload.get("childSessionId") @@ -461,15 +461,18 @@ class HarnessClient: def _notification_belongs_to_session_tree(self, session_id: str) -> NotificationFilter: def belongs(notification: Notification) -> bool: payload = notification.payload - related_ids = ( - payload.get("sessionId"), - payload.get("parentSessionId"), - payload.get("childSessionId"), - ) - return any( + if notification.method in {"subagent.started", "subagent.finished"}: + parent_id = payload.get("parentSessionId") + if ( + isinstance(parent_id, str) + and self._session_is_descendant_of(parent_id, session_id) + ): + return True + return payload.get("childSessionId") == session_id + related_id = payload.get("sessionId") + return ( isinstance(related_id, str) and self._session_is_descendant_of(related_id, session_id) - for related_id in related_ids ) return belongs diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index fda0686980..d5460b8683 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -9,7 +9,7 @@ from pathlib import Path import pytest -from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig +from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, Notification def test_high_level_sdk_runs_turn_and_collects_final_response(tmp_path: Path) -> None: @@ -442,6 +442,65 @@ def test_session_subscription_keeps_descendant_relationships_across_subscription assert client._notifications.qsize() == 0 +def test_session_subscription_preserves_reused_child_ancestry_after_late_finish() -> None: + client = HarnessClient() + old_seen: list[Notification] = [] + new_seen: list[Notification] = [] + with ( + client.subscribe_session_notifications("old-parent") as old_subscription, + client.subscribe_session_notifications("new-parent") as new_subscription, + ): + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.started", + "params": {"parentSessionId": "old-parent", "childSessionId": "reused-child"}, + }) + old_subscription.drain(old_seen.append) + new_subscription.drain(new_seen.append) + assert [notification.method for notification in old_seen] == ["subagent.started"] + assert new_seen == [] + + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.started", + "params": {"parentSessionId": "new-parent", "childSessionId": "reused-child"}, + }) + old_subscription.drain(old_seen.append) + new_subscription.drain(new_seen.append) + assert [notification.method for notification in new_seen] == ["subagent.started"] + + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.finished", + "params": {"parentSessionId": "old-parent", "childSessionId": "reused-child"}, + }) + old_subscription.drain(old_seen.append) + new_subscription.drain(new_seen.append) + assert [notification.method for notification in old_seen] == [ + "subagent.started", + "subagent.finished", + ] + assert [notification.method for notification in new_seen] == ["subagent.started"] + + client._handle_message({ + "jsonrpc": "2.0", + "method": "session.event", + "params": {"sessionId": "reused-child", "event": {"type": "assistant/message"}}, + }) + old_subscription.drain(old_seen.append) + new_subscription.drain(new_seen.append) + + assert [notification.method for notification in old_seen] == [ + "subagent.started", + "subagent.finished", + ] + assert [notification.method for notification in new_seen] == [ + "subagent.started", + "session.event", + ] + assert client._notifications.qsize() == 0 + + def test_client_contains_notification_filter_failure_to_its_subscription(tmp_path: Path) -> None: script = tmp_path / "fake_bridge.py" script.write_text( From 086e4549310436199c39ce9f860579a5f70416eb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:27:20 +0800 Subject: [PATCH 13/37] refactor(agent): name delivery methods by intent --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 17 +- ...ied-send-and-coalesced-user-messages.zh.md | 17 +- ...7-24-intent-named-agent-delivery.i18n.yaml | 6 + .../2026-07-24-intent-named-agent-delivery.md | 50 +++++ ...26-07-24-intent-named-agent-delivery.zh.md | 50 +++++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 32 +-- docs/architecture.zh.md | 28 +-- docs/cordis-catalog/events.md | 46 ++--- docs/core-data-structures/core.md | 194 +++++++----------- docs/event-producer-consumer.md | 36 ++-- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../time-context/tests/time-context.spec.ts | 2 +- .../tests/workspace-context.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 36 +--- packages/core/agent-loop/README.md | 4 +- packages/core/agent-loop/src/agent.ts | 99 +++++++-- packages/core/agent-loop/src/inbox.ts | 10 +- packages/core/agent-loop/tests/cancel.spec.ts | 8 +- packages/core/agent-loop/tests/loop.spec.ts | 2 +- packages/core/agent/README.md | 10 +- packages/core/agent/src/types.ts | 194 +++++++----------- packages/core/agent/tests/agent.spec.ts | 27 ++- .../command-goal/tests/command-goal.spec.ts | 6 +- .../goal-session/tests/goal-session.spec.ts | 8 +- packages/goal/goal/tests/goal.spec.ts | 8 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 6 +- packages/pty/pty-local/tests/index.spec.ts | 6 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 2 +- .../tool-pty/tests/loader-composition.spec.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- packages/tasks/tasks/tests/tasks.spec.ts | 2 +- packages/ui/tui/tests/harness.ts | 2 +- packages/ui/tui/tests/tui.spec.ts | 12 +- scripts/gen-cordis-api.ts | 51 +---- scripts/type-equiv.manifest.json | 7 +- 39 files changed, 514 insertions(+), 484 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md create mode 100644 .agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 7c3ed96243..c5c9c83dc8 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: 9eb355128b217a0ea8dc09daf4e83334f6aeaa10 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 679c9100aa49777b7b601725bdfc077f5f2dab0e +2026-07-22-unified-send-and-coalesced-user-messages.md: 9f6b29b4e1d90d75dbb88ab770c7393cdb859627 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 4469d211da23a24f61017770b29bc983a2abb5f1 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 9eb355128b..9f6b29b4e1 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -1,4 +1,4 @@ -# Agent Note: Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message +# Agent Note: Unify agent delivery and coalesce injected context into user/message Status: implemented @@ -12,17 +12,17 @@ Separately, `context/message` and `user/message` had converged: the surface proj ## Decision -**One primitive, three preset aliases.** `Agent` is now an abstract class whose single abstract `send(content, { target, wakeup, source, contexts, meta })` covers the (`target` × `wakeup`) matrix. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) are concrete delegates on the base class, so concrete drivers implement `send` once and inherit the ergonomic presets. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `send` defaults to `{ target: 'next-turn', wakeup: true }`, so every prior bare `agent.send(content)` call keeps its exact behavior. `next-turn`/no-wakeup (queue without waking) is now representable with no alias and no current caller. +**One private mechanism, four public intents.** The concrete loop resolves `send`, `queue`, `steer`, and `inject` into one private (`target` × `wakeup`) delivery mechanism. `send` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface exposes no routing fields or abstract base; the [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering. -**inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position (deferred behind an executing tool batch), or a one-shot `injection` turn when idle. It bypasses the FIFOs entirely and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. +**inject keeps its mechanism.** `inject` appends durable model-facing context at the current log position (deferred behind an executing tool batch), or opens a one-shot `injection` turn when idle. It bypasses the FIFOs entirely, accepts no attached contexts, and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. **context/message is gone.** Injected context is now a `user/message` whose `source` is a non-`user` kind (plugin or goal). `PromptMessageData` gained the optional `meta` that `context/message` carried. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. This keeps goal-authority's human-authority check exactly as strict as before — an injected message defaults to a plugin source and can never satisfy `source.kind === 'user'`. **Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata. -**`send` returns an id.** `send` (and the aliases) return an opaque branded `AgentMessageId` for the accepted message; `send`'s previous return was `void`. +**Delivery returns an id.** Each delivery method returns an opaque branded `AgentMessageId` for the accepted input. FIFO methods carry it through their inbox lifecycle events; injection bypasses those events. -**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) each carry an `AgentMessage` — the accepted message including its returned `id`, `target`/`wakeup`, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. +**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) each carry an `AgentMessage` — the accepted message including its returned `id`, steering/wakeup facts, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. **cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped). @@ -30,16 +30,17 @@ Separately, `context/message` and `user/message` had converged: the surface proj - **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Injected context defaults to a plugin source instead. - **A typed discriminant field on `PromptMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact. -- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the added `target`/`wakeup` facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. +- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the accepted routing facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. ## Consequences -The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. +The concrete driver has one delivery mechanism, while the public structural interface names four intents and hides its (`target` × `wakeup`) matrix. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every “human prompt?” check simplify to a `source` test. The goal fold's channel split moves from event type to `source.round`, and every consumer that filtered `context/message` filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged: an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. -`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it — both at the in-turn stop point and on the post-turn drain of late steering — and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. +Internally, `wakeup` is the “should the model run” signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `queue()` item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. `SendOptions.meta` on a queued or steering message is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally absent from the live `AgentMessage`, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it, both at the in-turn stop point and on the post-turn drain of late steering, and a loop-authored continuation reason is snapshotted and frozen like public steering. ## Related - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on. - [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md) — the precedent for collapsing a mirrored live event. - [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md) — the cancel-cause signal `keepInbox` extends. +- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md) — the public interface and private routing placement. diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 679c9100aa..4469d211da 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 将 agent 投递统一到 send(target × wakeup) 并把注入的上下文合并进 user/message +# Agent Note: 统一 agent 投递并把注入的上下文合并进 user/message Status: implemented @@ -12,17 +12,17 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` ## 决策 -**一个原语,三个预设别名。** `Agent` 现在是一个抽象类,其唯一的抽象方法 `send(content, { target, wakeup, source, contexts, meta })` 覆盖 (`target` × `wakeup`) 矩阵。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)是基类上的具体委托方法,因此具体驱动器只需实现一次 `send`,就能继承这些好用的预设。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`send` 默认使用 `{ target: 'next-turn', wakeup: true }`,因此此前每一次裸调用 `agent.send(content)` 都保持完全相同的行为。`next-turn`/no-wakeup(入队但不唤醒)现在可以表达,只是没有别名,也没有当前调用方。 +**一个私有机制,四种公开意图。** 具体循环把 `send`、`queue`、`steer` 和 `inject` 解析到同一个私有的(`target` × `wakeup`)投递机制中。`send` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口不暴露路由字段或抽象基类;取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。 -**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:在当前日志位置追加的持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时的一次性 `injection` 轮次。它完全绕过 FIFO 队列,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 +**inject 保留其机制。** `inject` 在当前日志位置追加持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时开启一个一次性的 `injection` 轮次。它完全绕过 FIFO,不接受附加上下文,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 **context/message 已移除。** 注入的上下文现在是一条 `user/message`,其 `source` 为非 `user` 类别(plugin 或 goal)。`PromptMessageData` 新增了 `context/message` 原本携带的可选 `meta`。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。这让 goal-authority 的人类授权检查与此前一样严格——注入的消息默认使用 plugin 来源,永远无法满足 `source.kind === 'user'`。 **goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,携带 `goal/change` 元数据;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 现在接收一条 `user/message`,并仍会在非 goal 来源携带 goal 元数据、或 goal 来源缺少元数据时立即报错。 -**`send` 返回一个 id。** `send`(以及其别名)为被接受的消息返回一个不透明的 branded `AgentMessageId`;`send` 此前的返回值是 `void`。 +**投递返回一个 id。** 每种投递方法都为被接受的输入返回一个不透明的 branded `AgentMessageId`。FIFO 方法通过其 inbox 生命周期事件携带这个 id;注入绕过这些事件。 -**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、`target`/`wakeup`、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 +**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、steering/wakeup 事实、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 **cancel 新增 keepInbox。** `cancel(cause?, { keepInbox? })`;当其为 true 时,它中止活跃轮次,但保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 @@ -30,16 +30,17 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` - **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。注入的上下文改为默认使用 plugin 来源。 - **在 `PromptMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。 -- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了 `target`/`wakeup` 的事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 +- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了已接受的路由事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 ## 后果 -投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 +具体驱动器只有一个投递机制,而公开的结构化接口以四种意图为方法命名,并隐藏其(`target` × `wakeup`)矩阵。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处「是否人类提示词?」检查都简化为一次 `source` 判断。goal 折叠的通道区分从事件类型改到 `source.round`,此前过滤 `context/message` 的每个消费方都改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变:空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`——既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时——而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 +在内部,`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `queue()` 项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。排队消息或 steering 消息上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`,既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时;而由 loop 生成的继续原因会像公开 steering 一样被快照并冻结。 ## 相关 - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。 - [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md)——折叠镜像实时事件的先例。 - [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md)——`keepInbox` 所扩展的取消原因信号。 +- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md)——公开接口和私有路由的归属位置。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml new file mode 100644 index 0000000000..45b232613e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-intent-named-agent-delivery.md: df58435e5a59dbc5e3c2ba17c624728533ed3b96 +2026-07-24-intent-named-agent-delivery.zh.md: 78cf25870d3f220373692caf943870b920fb151e diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md new file mode 100644 index 0000000000..df58435e5a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md @@ -0,0 +1,50 @@ +# Agent Note: Name public agent delivery by intent + +Status: implemented + +English | [中文](2026-07-24-intent-named-agent-delivery.zh.md) + +## Problem + +A public `send(content, { target, wakeup, ... })` makes every caller learn the loop's routing matrix, its defaults, and the interaction between active-turn targeting and model activation. Optional routing fields also let advanced-looking calls silently become ordinary sends. The mechanism is useful inside the concrete driver, but exposing it gives plugins implementation knowledge without leverage. + +Sharing helper implementations through an abstract `Agent` class also makes the public seam nominal in practice. Object-literal adapters and tests must inherit prototype methods even though the package promises a swappable structural handle. The shared base exists only to forward fixed arguments, while the concrete loop remains the sole production adapter. + +## Decision + +`Agent` is a structural interface with four intent-named delivery methods: + +- `send()` queues an ordinary turn and wakes the driver. +- `queue()` queues an ordinary turn without waking an idle driver. +- `steer()` targets the running turn and requests another step; while idle it becomes a waking ordinary turn. +- `inject()` appends model-facing context without running the model. + +`send`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` is absent: ordinary `send` already names the established common operation, and “follow-up” is false for a session's first message. + +`ReactLoopAgent` resolves each public call into one module-private `ResolvedDelivery` and passes it to native-private `#acceptDelivery`. Every internal field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. Injection resolves contexts to the empty tuple. The private name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. + +The target/wakeup matrix remains an implementation mechanism in `dsh-agent-loop`. It is not exported, protected, or represented by a base class. With one concrete adapter, a subclass delivery seam would be hypothetical; callers and tests use the same public `Agent` interface. + +## Alternatives considered + +**Keep a public configurable primitive.** Mandatory routing arguments remove defaulting mistakes but still require every caller to learn the matrix and allow invalid intent combinations such as attached contexts on injection. The concrete loop needs that flexibility; ordinary plugins do not. + +**Rename the primitive to `sendInternal` or `addMessageAdvanced`.** Visibility belongs in the type and runtime boundary, not a warning in a public name. `addMessageAdvanced` is also inaccurate because acceptance may wake, queue, steer, inject, or later discard work. + +**Keep `followup` as the waking-turn helper.** Existing production callers use `send`, while `followup` has no TypeScript caller and does not describe the first ordinary message. Reusing `send` preserves the familiar intent without retaining an alias. + +**Bind source first through a public sender object.** A source-bound adapter can make attribution explicit for repeated producers, but it adds another public object and does not simplify one-off human input. The existing source default remains, with the standing requirement that non-human producers label their content. + +## Verification + +Focused agent-loop coverage exercises waking sends, quiet queues, active and idle steering, injection, source/context snapshots, cancellation, and inbox lifecycle correlation through the public methods. Type-level coverage uses structural `Agent` fakes and rejects routing fields on `SendOptions` and contexts on `InjectOptions`. The keyless Cordis inspection snapshot pins the model-facing interface without a configurable delivery primitive or abstract-class implementation. + +## Consequences + +Callers choose one verb instead of encoding two routing axes. The concrete loop retains one acceptance path and one ownership boundary, while the structural interface restores simple adapters and fakes. Adding a delivery intent now requires an explicit public name and mapping rather than another matrix combination. + +Four short public methods duplicate a small amount of argument resolution inside the concrete adapter. That duplication is deliberate locality: defaults and routing stay beside the only implementation that owns them, and no generator support is needed merely to expose a class-shaped `Agent`. + +## Related + +- [unified delivery and coalesced user messages](2026-07-22-unified-send-and-coalesced-user-messages.md) owns the shared acceptance mechanism, inbox lifecycle, and durable event convergence this decision narrows at the public seam. diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md new file mode 100644 index 0000000000..78cf25870d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 按意图命名公开的 agent 投递 + +Status: implemented + +[English](2026-07-24-intent-named-agent-delivery.md) | 中文 + +## 问题 + +公开的 `send(content, { target, wakeup, ... })` 会迫使每个调用方理解循环的路由矩阵、默认值,以及活跃轮次目标与模型激活之间的相互作用。可选路由字段还会让看似高级的调用悄然变成普通投递。该机制在具体驱动器内部很有用,但将其公开只会让插件背负实现知识,却得不到相应收益。 + +通过抽象 `Agent` 类共享辅助方法的实现,实际上也会让公开 seam 具有名义类型约束。对象字面量适配器和测试必须继承原型方法,尽管该包承诺提供一个可替换的结构化句柄。共享基类只负责转发固定参数,而具体循环仍是唯一的生产适配器。 + +## 决策 + +`Agent` 是一个结构化接口,提供四种按意图命名的投递方法: + +- `send()` 将一个普通轮次入队并唤醒驱动器。 +- `queue()` 将一个普通轮次入队,但不唤醒空闲驱动器。 +- `steer()` 以运行中的轮次为目标并请求另一个步骤;空闲时,它会变成一个唤醒式普通轮次。 +- `inject()` 追加面向模型的上下文,但不运行模型。 + +`send`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。接口不提供 `followup`:普通 `send` 已经为既有的常见操作命名,而「follow-up」不适用于会话的第一条消息。 + +`ReactLoopAgent` 把每次公开调用解析为一个模块私有的 `ResolvedDelivery`,再将其传给原生私有的 `#acceptDelivery`。每个内部字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。注入会把上下文解析为空元组。这个私有名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 + +target/wakeup 矩阵仍是 `dsh-agent-loop` 中的实现机制。它不会导出,不是 protected 成员,也不由基类表示。只有一个具体适配器时,子类投递 seam 只是假想的;调用方和测试使用同一个公开 `Agent` 接口。 + +## 考虑过的替代方案 + +**保留公开的可配置原语。** 强制提供路由参数可以消除默认值错误,但仍会要求每个调用方理解矩阵,也允许出现无效的意图组合,例如为注入附加上下文。具体循环需要这种灵活性;普通插件不需要。 + +**把原语重命名为 `sendInternal` 或 `addMessageAdvanced`。** 可见性应由类型和运行时边界表达,而不是在公开名称中加入警告。`addMessageAdvanced` 也不准确,因为接受操作可能唤醒、排队、中途引导、注入,或在之后丢弃工作。 + +**保留 `followup` 作为唤醒轮次的辅助方法。** 现有生产调用方使用 `send`,而 `followup` 没有 TypeScript 调用方,也无法描述第一条普通消息。复用 `send` 可以保留熟悉的意图,同时不保留别名。 + +**先通过公开的发送方对象绑定来源。** 对于重复产生消息的来源,来源绑定适配器可以明确标注归属,但它会增加一个公开对象,也不会简化一次性的人类输入。现有的来源默认值予以保留,同时继续要求非人类生产方标注其内容。 + +## 验证 + +聚焦的 agent-loop 覆盖率测试通过公开方法覆盖唤醒式投递、静默排队、活跃与空闲状态下的 steering(中途引导)、注入、来源与上下文快照、取消,以及 inbox 生命周期关联。类型级覆盖使用结构化 `Agent` 测试替身,并拒绝 `SendOptions` 上的路由字段和 `InjectOptions` 上的上下文。无密钥的 Cordis 检查快照固定了面向模型的接口,其中既没有可配置的投递原语,也没有抽象类实现。 + +## 后果 + +调用方选择一个动词即可,无需编码两条路由轴。具体循环保留一条接受路径和一个归属边界,而结构化接口重新支持简单的适配器和测试替身。新增一种投递意图时,需要显式给出公开名称和映射,而不是再增加一种矩阵组合。 + +四个简短的公开方法会在具体适配器中重复少量参数解析。这项重复是为了让实现保持局部:默认值和路由都留在拥有它们的唯一实现旁边,无需仅为了暴露类形态的 `Agent` 而增加生成器支持。 + +## 相关 + +- [统一投递并合并 user 消息](2026-07-22-unified-send-and-coalesced-user-messages.md)负责定义共享的接受机制、inbox 生命周期和持久事件趋同;本决策只收窄它们的公开 seam。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 20553e1b90..7463516dba 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: d1051eecf51d8d1c7f8c235b0c5dd80478b43316 -architecture.zh.md: 502c0248a9d2c62af165ce07eb19489b76c5f6ee +architecture.md: edb42170fb508e522f2f26fd576902cc284e9eb4 +architecture.zh.md: e60fc2b2c61f139bbca59a3e9daa4703b2ce5976 diff --git a/docs/architecture.md b/docs/architecture.md index d1051eecf5..edb42170fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ English | [中文](architecture.zh.md) ## Overview -Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute services, typed events, and disposable registrations. +Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed services, typed events, and disposable registrations. `packages/core/` groups the default agent flow; capabilities remain plugins. @@ -49,7 +49,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute serv ## Event -Events form the service extension API; see the exhaustive [events catalog](cordis-catalog/events.md) and [producer/consumer map](event-producer-consumer.md). +Events form the service extension API; see the [catalog](cordis-catalog/events.md) and [producer/consumer map](event-producer-consumer.md). ### Event Domains @@ -63,11 +63,11 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop runs prompt-to-checkpoint work through plugin services and events. +The loop runs through plugin services and events. -A **session** is append-only. Each ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits the preceding claimed turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it; a **step** is one model request plus tools. In the [sequence below](agent-lifecycle.md), quotes mark durable events. +A **session** is append-only. Each ordinary **turn** claims one queued message; injection claims none. Successors await the preceding checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A **step** is one model request plus tools; quotes in the [sequence below](agent-lifecycle.md) mark durable events. -Without an id, creation mints `-session-`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent. +Creation without an id mints `-session-`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent. ### Turn Flow @@ -115,37 +115,37 @@ forever: checkpoint persistence and notify idle/running status ``` -Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona, while the loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). +Steps assemble ordered prompt sections, tool schemas, and variables; unknown references fail turns. `dsh-system-prompt` owns identity and persona; the loop supplies `model` and `cwd` ([ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Tool-time context—including async `inject()` and post-tool `additionalContexts`—settles after results. Steering drains before `agent/post-step`, which sees durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` remains authoritative through close/flush; later steering is discarded while queued prompts remain. +Async `inject()` and post-tool `additionalContexts` settle after results; steering drains before `agent/post-step`. Leftovers queue. Terminal `agent/turn-stop` remains authoritative through close/flush and discards later steering, not queued prompts. -Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). +Pruning precedes summaries; overflow retries require durable progress. Bounded retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). ### Failure Boundaries -Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retry opens another step; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit no message/tool. +Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retries open steps; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit nothing. -Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tool calls get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The turn signal retires before `turn/end`. Effective `cancel()` emits its typed cause before clearing queues and aborting; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). -Session events are turn-enclosed. Reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures report only through `agent/error`; no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. +Session events are turn-enclosed; reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures use `agent/error`. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). ### Agent Handles -`ctx.agents` owns live agents and returns `AgentHandle { agent, dispose() }`. Plugins use `send()`, `steer()`, `inject()`, `cancel()`, and `whenIdle()`. The caller fiber, factory provider, and consumer handle co-own teardown through one awaited disposer. +`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use intent-named `send()`, `queue()`, `steer()`, and `inject()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)); `cancel()` and `whenIdle()` control cancellation and quiescence. Caller, provider, and handle co-own one awaited teardown. ### Agent Scope -Each agent owns a scoped `agent.ctx`; shared storage overlays global tool, prompt, and command entries while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch, and every scoped contribution unwinds with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority remain explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). +Each agent owns a scoped `agent.ctx` over global tool, prompt, and command storage ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)); scoped listeners filter and contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication; typed resolvers derive carrier checks from `Events` and `scopeTarget` ([gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). ## State ### Session Log -The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events remain for replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from the same stream. +The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence share that stream. -**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ logged**: `step/start` messages plus the header's session prefix and folded `request/header` reconstruct every request; `dsh-agent-loop/invariant` asserts this through `ctx.invariants` ([decision](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). -Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). +Durability is a plugin concern; backends buffer synchronous `session/event` notifications. Checkpoints drain before adapter dispatch, recorded top-level tool calls before tool dispatch, complete response/result batches at `agent/post-step`, and final turn ends. `SessionPersistence` stores `SessionEvent` plus `SessionHeader` metadata; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). `ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 502c0248a9..e60fc2b2c6 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -49,7 +49,7 @@ ## 事件 -事件构成服务的扩展 API;完整清单见[事件目录](cordis-catalog/events.md)和[生产方与消费方映射](event-producer-consumer.md)。 +事件构成服务的扩展 API;参见[事件目录](cordis-catalog/events.md)和[生产方与消费方映射](event-producer-consumer.md)。 ### 事件域 @@ -63,9 +63,9 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 ` ## 默认循环生命周期 -已交付的循环通过插件服务和事件,处理从提示词到检查点的工作。 +循环通过插件服务和事件运行。 -**会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 +**会话**采用仅追加方式。每个普通**轮次**领取一条已排队的消息;注入不领取消息。后续轮次会等待前一个检查点,但可以与前一轮次共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。一个**步骤**包含一次模型请求及其工具;在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 未提供 id 时,创建流程会生成 `-session-`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;拆卸过程保持静默。 @@ -115,37 +115,37 @@ forever: checkpoint persistence and notify idle/running status ``` -每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定,循环则提供 `model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 +各步骤会组装有序提示词片段、工具 schema 和变量;未知引用会使轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `model` 和 `cwd`([归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -工具执行阶段的上下文,包括异步 `inject()` 和工具执行后的 `additionalContexts`,会在结果产生后稳定。steering(中途引导)会在 `agent/post-step` 前排空;该事件会观察持久输出、结果、上下文和 steering。余留内容进入队列。终止型 `agent/turn-stop` 在关闭和刷写期间始终具有最终决定权;后续 steering 会被丢弃,排队提示词仍予保留。 +异步 `inject()` 和工具执行后的 `additionalContexts` 会在结果产生后稳定;steering(中途引导)会在 `agent/post-step` 前排空。余留内容进入队列。终止型 `agent/turn-stop` 在关闭和刷写期间始终具有最终决定权,会丢弃后续 steering,而不丢弃排队提示词。 -裁剪先于摘要;溢出重试必须取得持久进展。有界的瞬态重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。 +裁剪先于摘要;溢出重试必须取得持久进展。有界重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。 ### 失败边界 -适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启另一个步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交消息或工具。 +适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交任何内容。 -其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具调用会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。轮次信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会在清空队列和中止前发出类型化原因;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 -会话事件均位于轮次边界内。重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障只通过 `agent/error` 报告;此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。 +会话事件均位于轮次边界内;重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障使用 `agent/error`。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 ### Agent 句柄 -`ctx.agents` 拥有活跃 agent,并返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`、`steer()`、`inject()`、`cancel()` 和 `whenIdle()`。调用方 fiber、工厂提供方和消费方句柄通过同一个需等待完成的 disposer 共同拥有拆卸过程。 +`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用按意图命名的 `send()`、`queue()`、`steer()` 和 `inject()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md));`cancel()` 和 `whenIdle()` 控制取消和停稳过程。调用方、提供方和句柄共同拥有一项需等待完成的拆卸过程。 ### Agent 作用域 -每个 agent 都拥有一个作用域化的 `agent.ctx`;共享存储会在全局工具、提示词和命令条目之上叠加作用域条目,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派,每项作用域贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。 +每个 agent 都拥有一个作用于全局工具、提示词和命令存储的作用域化 `agent.ctx`([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md));作用域监听器会过滤分派,各项贡献会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合;类型化解析器从 `Events` 和 `scopeTarget` 推导载体检查([门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。 ## 状态 ### 会话日志 -会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件留在日志中,以保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自同一个事件流。 +会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件保留回放和 UI 保真。fork、恢复、transcript(文本记录)、遥测和持久化共用该事件流。 -**模型可见 ⟺ 已记录**:日志可以重建每个请求,包括由请求头会话前缀置于开头的 `step/start` 时消息,以及通过折叠 `request/header` 得到的请求头;开发期不变量会断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 +**模型可见 ⟺ 已记录**:`step/start` 消息、请求头中的会话前缀和折叠后的 `request/header` 共同重建每个请求;`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言这一点([决策](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 -持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 +持久性由插件负责;后端会缓冲同步的 `session/event` 通知。检查点会在适配器分发前排空,在工具分发前刷写已记录的顶层工具调用,在 `agent/post-step` 刷写完整的响应与结果批次,并刷写最终的轮次结束。`SessionPersistence` 存储 `SessionEvent` 和 `SessionHeader` 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 `ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 56c927e0db..fa8494b14c 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:503`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:463`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -138,11 +138,11 @@ Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/t Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit -A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. +A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` bypasses the FIFOs and does not emit this. ```ts cordis-catalog /** @@ -150,7 +150,7 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record * is the eventual `user/message`/`steering/message`. Injection - * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. + * through `agent.inject()` bypasses the FIFOs and does not emit this. * @param agent - the agent whose inbox received the item. * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -161,7 +161,7 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:325`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -184,7 +184,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:453`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:413`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -207,7 +207,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:384`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:344`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -234,7 +234,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -259,7 +259,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:414`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:374`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -285,7 +285,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:468`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:428`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -311,7 +311,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:429`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:389`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -333,16 +333,16 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:328`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit -Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does not enter `running` synchronously; drive lifecycle from this event. +Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking delivery does not enter `running` synchronously; drive lifecycle from this event. ```ts cordis-catalog /** - * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does - * not enter `running` synchronously; drive lifecycle from this event. + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking + * delivery does not enter `running` synchronously; drive lifecycle from this event. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -353,7 +353,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -376,7 +376,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) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:441`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:401`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -398,7 +398,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:479`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:439`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -420,7 +420,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:490`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:450`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 37e6ddc6dc..85689a89d6 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -361,35 +361,11 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types ```ts type-equiv /** - * Which inbox queue a {@link Agent.send} item joins: - * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. - * - `next-step` — the item joins the active turn between steps as steering, - * or, when no turn is active, is promoted per its `wakeup` flag. - */ -type SendTarget = 'next-turn' | 'next-step' -``` - -```ts type-equiv -/** - * Options for the unified {@link Agent.send} primitive over the - * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} - * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and - * {@link Agent.inject} (`next-step`/no-wakeup). - * + * Options for {@link Agent.send}, {@link Agent.queue}, and {@link Agent.steer}. * An omitted source attests direct human input as `{ kind: 'user' }` and may * authorize policy consumers, so non-human producers must label their content. */ interface SendOptions { - /** Queue the item joins; defaults to `next-turn`. */ - target?: SendTarget - /** - * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). Defaults to - * `true`. A `false` `next-turn` item queues without waking; a `false` - * `next-step` item attaches durable context without forcing another step - * (the injection preset). - */ - wakeup?: boolean source?: MessageSource /** * Model-facing contexts captured with this inbox item. A queued prompt exposes @@ -402,19 +378,22 @@ interface SendOptions { } ``` -The fixed-preset aliases own `target` and `wakeup`, so they accept only the remaining fields: - ```ts type-equiv -/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ -type AliasSendOptions = Omit +/** Options specific to durable synthetic context injection. */ +interface InjectOptions { + /** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */ + source?: MessageSource + /** Opaque JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue +} ``` -`send` returns the accepted message's opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events: +FIFO delivery methods return an opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events. Injection returns an id but bypasses those events: ```ts type-equiv /** - * Opaque id assigned to one accepted {@link Agent.send} message; returned by - * `send` and carried on its `agent/inbox/*` events for correlation. + * Opaque id assigned to one accepted agent input. FIFO inputs carry the same id + * on their `agent/inbox/*` events; injection bypasses those events. */ type AgentMessageId = Branded<'AgentMessageId'> ``` @@ -423,24 +402,24 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t ```ts type-equiv /** - * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live - * events. `id` is the value `send` returned to the caller, stable across this - * message's enqueue, dequeue, and discard events. Source defaults are already - * applied, so these are the exact values the item was accepted with. `steering` - * is true for a `next-step` item drained between steps; a `next-turn` item is - * claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is - * durable model-hidden state that lands on the eventual `user/message`/ + * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` + * is the value `send`, `queue`, or `steer` returned to the caller, stable across + * this message's enqueue, dequeue, and discard events. Source defaults are + * already applied, so these are the exact values the item was accepted with. + * `steering` is true for an item drained between steps; otherwise it is claimed + * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable + * model-hidden state that lands on the eventual `user/message`/ * `steering/message`, not live-event routing data. */ interface AgentMessage { - /** The id `send` returned for this message. */ + /** The id returned by the accepting `send`, `queue`, or `steer` call. */ id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] - /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ + /** Whether the item joined the steering FIFO rather than the queued FIFO. */ steering: boolean - /** Whether the item is marked to wake the driver or force a continuation. */ + /** Whether the item wakes the driver or requests another step. */ wakeup: boolean } ``` @@ -464,49 +443,69 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` -`Agent` is an abstract class: concrete drivers implement the abstract members, while `followup`/`steer`/`inject` are shared concrete delegates to the single abstract `send` over the (`target` × `wakeup`) matrix. +The structural `Agent` interface exposes four delivery intents. The concrete driver resolves them into a private routing mechanism rather than exporting the target/wakeup matrix. ```ts type-equiv -/** - * Public agent handle; its concrete implementation is internal to - * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so - * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer}, - * {@link Agent.inject}) are shared concrete delegates over the single abstract - * {@link Agent.send} primitive; concrete drivers implement `send` once. - */ -abstract class Agent { +/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ +interface Agent { /** The single identity shared with {@link session}. */ - abstract readonly id: SessionId + readonly id: SessionId /** The provider route and model this agent's requests use. */ - abstract readonly options: AgentOptions + readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ - abstract readonly session: Session + readonly session: Session /** The current lifecycle state, mirrored on every `agent/status` transition. */ - abstract readonly status: AgentStatus + readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - abstract readonly ctx: Context + readonly ctx: Context /** - * The unified delivery primitive over the (`target` × `wakeup`) matrix. - * Detaches, validates, and freezes one lossless-JSON item, then routes it: - * - * - `next-turn` (default) queues an item that becomes the sole ordinary - * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a - * parked driver, while `wakeup:false` queues without waking. - * - `next-step` with `wakeup:true` submits steering into the active turn - * (idle falls back to a woken `next-turn`). - * - `next-step` with `wakeup:false` injects durable model-facing context - * without running the model: an open turn joins at the current log position - * (deferred behind an executing tool batch until it settles), and an idle - * inject records a one-shot turn with its own durability checkpoint. - * - * Attached contexts share the same snapshot and ownership boundary. Invalid - * input throws synchronously before any notification, enqueue, or append. - * @param content - the model-facing content blocks to deliver. - * @param options - target queue, wakeup decision, source, contexts, and meta. + * Queue an ordinary message as its own FIFO-ordered turn and wake the driver. + * Content, resolved source, and attached contexts are detached, validated, + * and frozen together; invalid input throws synchronously before notification + * or enqueue. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId + send(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Queue an ordinary message without waking an idle driver. The item retains + * FIFO order and is claimed only after another input wakes the driver. A lone + * queued item leaves `whenIdle()` resolved. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + */ + queue(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Submit steering into the running turn and request another step. An open turn + * records it at the next steering checkpoint before a request or continuation + * decision; policy may stop before another step. After turn close and its + * checkpoint, any remainder is queued for a later turn; terminal + * `agent/turn-stop`, cancellation, or disposal may discard it. Idle steering + * becomes a waking ordinary turn. + * @param content - the steering content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + */ + steer(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Append detached model-facing context without running the model. An open-turn + * injection joins at the current log position unless the current tool batch is + * executing; then it waits FIFO until that batch settles and drains before + * turn close even when interrupted. Idle injection uses a one-shot turn and + * durability checkpoint. Disposal awaits idle checkpoints; flush failures + * report through `agent/error`. An omitted source defaults to + * `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and durable model-hidden meta. + * @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events. + */ + inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -518,53 +517,10 @@ abstract class Agent { * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ - abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void + cancel(cause?: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ - abstract whenIdle(): Promise - - /** - * Queue an ordinary follow-up turn and wake the driver — the - * `next-turn`/wakeup preset of {@link send}. The item becomes the sole - * ordinary message of its own turn. - * @param content - the prompt content blocks. - * @param options - source and attached contexts. - * @returns the accepted message's {@link AgentMessageId}. - */ - followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-turn', wakeup: true }) - } - - /** - * Submit steering into the running turn — the `next-step`/wakeup preset of - * {@link send}. An open turn records it at the next steering checkpoint before - * a request or continuation decision; policy may stop before another step. - * After turn close and its checkpoint, any remainder is queued for a later - * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. - * Idle steering falls back to a woken follow-up turn. - * @param content - the steering content blocks. - * @param options - source and attached contexts. - * @returns the accepted message's {@link AgentMessageId}. - */ - steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-step', wakeup: true }) - } - - /** - * Append detached model-facing context without running the model — the - * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins - * at the current log position unless the current tool batch is executing; - * then it waits FIFO until that batch settles and drains before turn close - * even when interrupted. Idle injection uses a one-shot turn and durability - * checkpoint. Disposal awaits idle checkpoints; flush failures report through - * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. - * @param content - the injected context content blocks. - * @param options - source and durable model-hidden meta. - * @returns the accepted message's {@link AgentMessageId}. - */ - inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-step', wakeup: false }) - } + whenIdle(): Promise } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f0469118ba..5337683ccd 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,24 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:503`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:335`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:325`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:453`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:384`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:400`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:414`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:468`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:429`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:441`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:479`](../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), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:490`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:305`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:413`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:344`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:374`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:428`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:389`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:328`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:401`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:439`](../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), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:450`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 075138effc..e9d139c8be 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index af219d02db..1121d8e9cd 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index ae1b4c5a0b..0f196a590a 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -43,7 +43,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { status: 'running', ctx: new Context(), send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { session.append('user/message', { diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 161466a8b8..6b7da3b19c 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -178,7 +178,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { session, status: 'idle', send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { session.append('user/message', { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 150c4880b3..446ded7d0e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -899,7 +899,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/enqueue', mode: 'emit', signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, message: AgentMessage): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * through `agent.inject()` bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', }, { @@ -955,7 +955,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/status', mode: 'emit', signature: '\'agent/status\'(this: Scoped, agent: Agent, status: AgentStatus): void', - jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does\n * not enter `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking\n * delivery does not enter `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'Agent status changed (`idle` ⇄ `running`, or → `disposed`).', }, { @@ -1181,7 +1181,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n}', }, { name: 'AgentCancelCause', @@ -1207,10 +1207,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';', }, - { - name: 'AliasSendOptions', - declaration: 'export type AliasSendOptions = Omit;', - }, { name: 'ApprovalOutcome', declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';', @@ -1523,6 +1519,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'HookContext', declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n meta?: JsonValue;\n}', }, + { + name: 'InjectOptions', + declaration: 'export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n}', + }, { name: 'InvariantFailure', declaration: 'export type InvariantFailure = (message: string) => never;', @@ -1547,10 +1547,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', }, - { - name: 'LlmAdapter', - declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise;\n resolveModelContext(_provider: string, _model: string): Promise;\n abstract stream(options: GenerateOptions): AsyncIterable;\n}', - }, { name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', @@ -1753,15 +1749,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SendOptions', - declaration: 'export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}', - }, - { - name: 'SendTarget', - declaration: 'export type SendTarget = \'next-turn\' | \'next-step\';', - }, - { - name: 'Session', - declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + declaration: 'export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}', }, { name: 'SessionAvailability', @@ -1895,10 +1883,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionSearchRequest', declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', }, - { - name: 'SessionSurface', - declaration: 'export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n}', - }, { name: 'SessionSurfaceSnapshot', declaration: 'export interface SessionSurfaceSnapshot {\n session: SessionHeader;\n capturedThroughSeq: number | null;\n events: SurfaceEvent[];\n}', @@ -2031,10 +2015,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SurfaceEventType', declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';', }, - { - name: 'SurfaceIntent', - declaration: 'export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n}', - }, { name: 'SurfaceOp', declaration: 'export type SurfaceOp = \'append\' | {\n op: \'replace\';\n start: number;\n end: number;\n};', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index d2d18b61b2..68677876f8 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -50,9 +50,9 @@ Configured agents start automatically. A model call requires both `provider` and ### Internal concrete driver -The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. +The concrete `ReactLoopAgent` adapter, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -The unified `send()` primitive materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON record, then routes it by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO (waking the driver unless `wakeup: false`); if claimed, it is the sole ordinary message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. A running `next-step`/wakeup `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `next-step`/no-wakeup `inject()` bypasses the FIFOs and appends durable context directly: an open-turn injection uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. +`ReactLoopAgent` maps the public `send()`/`queue()`/`steer()`/`inject()` intents onto native-private `#acceptDelivery`. Each public method resolves every optional field before the private mechanism receives mandatory content, source, contexts, metadata, target, and wakeup facts; no configurable delivery primitive crosses the package seam. `send()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` accepts no attached contexts, bypasses both FIFOs, and appends durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index baa2e3f08d..8dc6e95808 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -9,11 +9,19 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' -import { Agent } from '@deepseek-ai/dsh-agent' -import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, SendOptions } from '@deepseek-ai/dsh-agent' +import type { + Agent, + AgentCancelCause, + AgentOptions, + AgentStatus, + CancelOptions, + HookContext, + InjectOptions, + SendOptions, +} from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' +import { snapshotJsonValue, type JsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts' import { Inbox, agentMessage, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' @@ -33,6 +41,17 @@ const bindContext = Symbol('dsh.agent-loop.bind-context') /** Module-private publication marker. */ const publishAgent = Symbol('dsh.agent-loop.publish-agent') +/** Fully resolved input accepted only by the concrete driver's private delivery mechanism. */ +type ResolvedDelivery = { + content: ContentBlock[] + source: MessageSource + meta: JsonValue | undefined +} & ( + | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } + | { target: 'next-step'; wakeup: true; contexts: HookContext[] } + | { target: 'next-step'; wakeup: false; contexts: [] } +) + /** Factory-owned controls that can operate only on the agent created with them. */ export interface PreparedReactLoopAgent { /** The unpublished concrete agent. */ @@ -101,7 +120,7 @@ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): * the loop driver. Everything observable happens through session events and * the agent/* event taxonomy — plugins never need this class. */ -export class ReactLoopAgent extends Agent { +export class ReactLoopAgent implements Agent { /** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */ readonly #inbox = new Inbox() @@ -162,7 +181,6 @@ export class ReactLoopAgent extends Agent { public readonly session: Session, maxParallelToolCalls: number, ) { - super() this.maxParallelToolCalls = maxParallelToolCalls const { promise, resolve } = Promise.withResolvers() this.disposed = promise @@ -197,13 +215,11 @@ export class ReactLoopAgent extends Agent { * materialization reads every nested field once; deep freeze prevents later * caller mutation before an inbox or deferred-injection queue drains it. */ - private acceptMessage( - id: AgentMessageId, content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions, - ): InboxMessage { - const contexts = options?.contexts ?? [] + private snapshotMessage(id: AgentMessageId, delivery: ResolvedDelivery): InboxMessage { + const { content, source, contexts, wakeup, meta } = delivery const accepted = snapshotJsonValue({ id, content, source, contexts, wakeup, - ...options?.meta !== undefined ? { meta: options.meta } : {}, + ...meta !== undefined ? { meta } : {}, }) if (accepted === undefined) { throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable') @@ -225,18 +241,17 @@ export class ReactLoopAgent extends Agent { if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) } - send(content: ContentBlock[], options?: SendOptions): AgentMessageId { + /** Accept one fully resolved intent through the concrete driver's private routing matrix. */ + #acceptDelivery(delivery: ResolvedDelivery): AgentMessageId { this.assertNotDisposed() const id = AgentMessageId(randomUUID()) - const target = options?.target ?? 'next-turn' - const wakeup = options?.wakeup ?? true + const { target, wakeup } = delivery // next-step/no-wakeup is injection: durable context without running the model. - if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return id } + if (target === 'next-step' && !wakeup) { this.injectContext(delivery); return id } // next-step/wakeup is steering into the running turn; idle falls back to a - // woken follow-up turn (there is no active turn to attach to). + // waking ordinary turn (there is no active turn to attach to). const steering = target === 'next-step' && this._status === 'running' - const source = options?.source ?? { kind: 'user' } - const accepted = this.acceptMessage(id, content, source, wakeup, options) + const accepted = this.snapshotMessage(id, delivery) if (steering) { this.#inbox.steer(accepted) } else { @@ -246,13 +261,57 @@ export class ReactLoopAgent extends Agent { return id } + send(content: ContentBlock[], options?: SendOptions): AgentMessageId { + return this.#acceptDelivery({ + content, + target: 'next-turn', + wakeup: true, + source: options?.source ?? { kind: 'user' }, + contexts: options?.contexts ?? [], + meta: options?.meta, + }) + } + + queue(content: ContentBlock[], options?: SendOptions): AgentMessageId { + return this.#acceptDelivery({ + content, + target: 'next-turn', + wakeup: false, + source: options?.source ?? { kind: 'user' }, + contexts: options?.contexts ?? [], + meta: options?.meta, + }) + } + + steer(content: ContentBlock[], options?: SendOptions): AgentMessageId { + return this.#acceptDelivery({ + content, + target: 'next-step', + wakeup: true, + source: options?.source ?? { kind: 'user' }, + contexts: options?.contexts ?? [], + meta: options?.meta, + }) + } + + inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId { + return this.#acceptDelivery({ + content, + target: 'next-step', + wakeup: false, + source: options?.source ?? { kind: 'plugin', plugin: '' }, + contexts: [], + meta: options?.meta, + }) + } + /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ - private injectContext(content: ContentBlock[], options?: SendOptions): void { - const source = options?.source ?? { kind: 'plugin', plugin: '' } + private injectContext(delivery: Extract): void { + const { content, source, meta } = delivery const context = { content, source, - ...options?.meta !== undefined ? { meta: options.meta } : {}, + ...meta !== undefined ? { meta } : {}, } if (isTurnOpen(this.session)) { const accepted = this.acceptContext(context) diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index 3cb82944e4..8641d20321 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -1,7 +1,7 @@ /** * Per-agent message inbox: queued and steering FIFOs. Purely an in-memory - * mechanism of the loop driver — the public surface is `Agent.send()` and its - * fixed-preset aliases. + * mechanism of the loop driver — callers use `Agent`'s intent-named delivery + * methods instead. * * @module dsh-agent-loop/inbox */ @@ -10,7 +10,7 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-session' import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent' -/** One message waiting in an agent's inbox; `id` is the value `send` returned. */ +/** One message waiting in an agent's inbox; `id` is the value its accepting delivery method returned. */ export interface InboxMessage { id: AgentMessageId content: ContentBlock[] @@ -35,7 +35,7 @@ export function agentMessage(message: InboxMessage, steering: boolean): AgentMes /** * Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO * (drained between steps of a running turn). Purely an in-memory mechanism of - * the loop — the public surface is `Agent.send()` and its aliases. + * the loop — the public surface is `Agent`'s intent-named delivery methods. */ export class Inbox { private queuedMessages: InboxMessage[] = [] @@ -78,7 +78,7 @@ export class Inbox { /** * Add a message to the steering FIFO. Deliberately no wakeup: steering is * drained between steps of a running turn, never by the idle wait — - * `Agent.steer()` on an idle agent falls back to a woken follow-up instead. + * `Agent.steer()` on an idle agent falls back to a waking ordinary turn instead. * @param message - the message to inject between steps of the running turn. */ steer(message: InboxMessage): void { diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 3a7af2871f..62b75df154 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -106,7 +106,7 @@ describe('Agent.cancel()', () => { ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) }) // Queue a turn WITHOUT waking the driver, so it sits in the inbox. - agent.send([{ type: 'text', text: 'preserved' }], { target: 'next-turn', wakeup: false }) + agent.queue([{ type: 'text', text: 'preserved' }]) // keepInbox cancel: no active turn, work preserved, no discard event. agent.cancel({ kind: 'user' }, { keepInbox: true }) expect(discards).toEqual([]) @@ -117,14 +117,14 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['preserved', 'wake it']) }) - it('a lone quiet (wakeup:false) send leaves the agent parked at idle', async () => { + it('a lone queued message leaves the agent parked at idle', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // A quiet item alone must NOT wake the driver: no turn runs and whenIdle // resolves (the agent is quiescent), leaving the item queued. - agent.send([{ type: 'text', text: 'quiet' }], { target: 'next-turn', wakeup: false }) + agent.queue([{ type: 'text', text: 'quiet' }]) await agent.whenIdle() expect(agent.status).toBe('idle') expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) @@ -140,7 +140,7 @@ describe('Agent.cancel()', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'quiet' }], { target: 'next-turn', wakeup: false }) + agent.queue([{ type: 'text', text: 'quiet' }]) const idle = agent.whenIdle() // Cancel reaches quiescence with no status transition and no waking send; // whenIdle must still resolve (previously it hung until the next send). diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 8d3016d872..8b8a4c8c92 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -539,7 +539,7 @@ describe('agent loop', () => { })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }], { target: 'next-turn', wakeup: true, meta: { prompt: 1 } }) + agent.send([{ type: 'text', text: 'go' }], { meta: { prompt: 1 } }) await waitForIdle(ctx, agent) const user = agent.session.events.find(e => e.type === 'user/message') diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index ebb756b139..647cae2b22 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,12 +54,12 @@ Turn and step boundaries and the model token stream are durable `session/event` ### Agent interface (`types.ts`) -The handle every plugin programs against: +`Agent` is a structural interface. Public delivery methods name caller intent; the concrete driver keeps queue targeting and wakeup routing private ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `send()`, `queue()`, and `steer()` return an opaque `AgentMessageId` carried by that FIFO item's `agent/inbox/enqueue`/`dequeue`/`discard` events. Each snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. Omitting `options.source` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content. -- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. It returns the accepted message's opaque `AgentMessageId`, which the message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry so a caller can correlate a queued item with its lifecycle. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/inbox/enqueue` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. -- `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. -- `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. -- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. +- `agent.send(content, options?)` — queue one independent FIFO message as its own turn and wake the driver. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.queue(content, options?)` — queue the same ordinary message without waking an idle driver. A lone queued item leaves `whenIdle()` resolved and rides along before the next waking message. +- `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; while idle, create a waking ordinary turn. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. +- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `InjectOptions` deliberately has no attached contexts. `options.meta` persists opaque JSON state without rendering it. While a turn is open the injection joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. - `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `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.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index d542c39810..b120fb6689 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -27,33 +27,11 @@ export interface AgentOptions { } /** - * Which inbox queue a {@link Agent.send} item joins: - * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. - * - `next-step` — the item joins the active turn between steps as steering, - * or, when no turn is active, is promoted per its `wakeup` flag. - */ -export type SendTarget = 'next-turn' | 'next-step' - -/** - * Options for the unified {@link Agent.send} primitive over the - * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} - * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and - * {@link Agent.inject} (`next-step`/no-wakeup). - * + * Options for {@link Agent.send}, {@link Agent.queue}, and {@link Agent.steer}. * An omitted source attests direct human input as `{ kind: 'user' }` and may * authorize policy consumers, so non-human producers must label their content. */ export interface SendOptions { - /** Queue the item joins; defaults to `next-turn`. */ - target?: SendTarget - /** - * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). Defaults to - * `true`. A `false` `next-turn` item queues without waking; a `false` - * `next-step` item attaches durable context without forcing another step - * (the injection preset). - */ - wakeup?: boolean source?: MessageSource /** * Model-facing contexts captured with this inbox item. A queued prompt exposes @@ -65,12 +43,17 @@ export interface SendOptions { meta?: JsonValue } -/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ -export type AliasSendOptions = Omit +/** Options specific to durable synthetic context injection. */ +export interface InjectOptions { + /** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */ + source?: MessageSource + /** Opaque JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue +} /** - * Opaque id assigned to one accepted {@link Agent.send} message; returned by - * `send` and carried on its `agent/inbox/*` events for correlation. + * Opaque id assigned to one accepted agent input. FIFO inputs carry the same id + * on their `agent/inbox/*` events; injection bypasses those events. */ export type AgentMessageId = Branded<'AgentMessageId'> @@ -84,24 +67,24 @@ export function AgentMessageId(id: string): AgentMessageId { } /** - * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live - * events. `id` is the value `send` returned to the caller, stable across this - * message's enqueue, dequeue, and discard events. Source defaults are already - * applied, so these are the exact values the item was accepted with. `steering` - * is true for a `next-step` item drained between steps; a `next-turn` item is - * claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is - * durable model-hidden state that lands on the eventual `user/message`/ + * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` + * is the value `send`, `queue`, or `steer` returned to the caller, stable across + * this message's enqueue, dequeue, and discard events. Source defaults are + * already applied, so these are the exact values the item was accepted with. + * `steering` is true for an item drained between steps; otherwise it is claimed + * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable + * model-hidden state that lands on the eventual `user/message`/ * `steering/message`, not live-event routing data. */ export interface AgentMessage { - /** The id `send` returned for this message. */ + /** The id returned by the accepting `send`, `queue`, or `steer` call. */ id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] - /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ + /** Whether the item joined the steering FIFO rather than the queued FIFO. */ steering: boolean - /** Whether the item is marked to wake the driver or force a continuation. */ + /** Whether the item wakes the driver or requests another step. */ wakeup: boolean } @@ -119,7 +102,7 @@ export interface CancelOptions { * An agent's lifecycle state, emitted on every transition as `agent/status`: * `idle` (parked, waiting for queued work), `running` (the driver is draining * work and may be closing or checkpointing a turn), `disposed` (terminal — no - * transition leaves it, and `send`/`followup`/`steer`/`inject` throw). + * transition leaves it, and `send`/`queue`/`steer`/`inject` throw). */ export type AgentStatus = 'idle' | 'running' | 'disposed' @@ -179,46 +162,66 @@ export type AgentCancelCause = /** Runtime reason carried by the signal that controls one live turn. */ export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' } -/** - * Public agent handle; its concrete implementation is internal to - * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so - * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer}, - * {@link Agent.inject}) are shared concrete delegates over the single abstract - * {@link Agent.send} primitive; concrete drivers implement `send` once. - */ -export abstract class Agent { +/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ +export interface Agent { /** The single identity shared with {@link session}. */ - abstract readonly id: SessionId + readonly id: SessionId /** The provider route and model this agent's requests use. */ - abstract readonly options: AgentOptions + readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ - abstract readonly session: Session + readonly session: Session /** The current lifecycle state, mirrored on every `agent/status` transition. */ - abstract readonly status: AgentStatus + readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - abstract readonly ctx: Context + readonly ctx: Context /** - * The unified delivery primitive over the (`target` × `wakeup`) matrix. - * Detaches, validates, and freezes one lossless-JSON item, then routes it: - * - * - `next-turn` (default) queues an item that becomes the sole ordinary - * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a - * parked driver, while `wakeup:false` queues without waking. - * - `next-step` with `wakeup:true` submits steering into the active turn - * (idle falls back to a woken `next-turn`). - * - `next-step` with `wakeup:false` injects durable model-facing context - * without running the model: an open turn joins at the current log position - * (deferred behind an executing tool batch until it settles), and an idle - * inject records a one-shot turn with its own durability checkpoint. - * - * Attached contexts share the same snapshot and ownership boundary. Invalid - * input throws synchronously before any notification, enqueue, or append. - * @param content - the model-facing content blocks to deliver. - * @param options - target queue, wakeup decision, source, contexts, and meta. + * Queue an ordinary message as its own FIFO-ordered turn and wake the driver. + * Content, resolved source, and attached contexts are detached, validated, + * and frozen together; invalid input throws synchronously before notification + * or enqueue. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId + send(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Queue an ordinary message without waking an idle driver. The item retains + * FIFO order and is claimed only after another input wakes the driver. A lone + * queued item leaves `whenIdle()` resolved. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + */ + queue(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Submit steering into the running turn and request another step. An open turn + * records it at the next steering checkpoint before a request or continuation + * decision; policy may stop before another step. After turn close and its + * checkpoint, any remainder is queued for a later turn; terminal + * `agent/turn-stop`, cancellation, or disposal may discard it. Idle steering + * becomes a waking ordinary turn. + * @param content - the steering content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + */ + steer(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Append detached model-facing context without running the model. An open-turn + * injection joins at the current log position unless the current tool batch is + * executing; then it waits FIFO until that batch settles and drains before + * turn close even when interrupted. Idle injection uses a one-shot turn and + * durability checkpoint. Disposal awaits idle checkpoints; flush failures + * report through `agent/error`. An omitted source defaults to + * `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and durable model-hidden meta. + * @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events. + */ + inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -230,53 +233,10 @@ export abstract class Agent { * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ - abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void + cancel(cause?: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ - abstract whenIdle(): Promise - - /** - * Queue an ordinary follow-up turn and wake the driver — the - * `next-turn`/wakeup preset of {@link send}. The item becomes the sole - * ordinary message of its own turn. - * @param content - the prompt content blocks. - * @param options - source and attached contexts. - * @returns the accepted message's {@link AgentMessageId}. - */ - followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-turn', wakeup: true }) - } - - /** - * Submit steering into the running turn — the `next-step`/wakeup preset of - * {@link send}. An open turn records it at the next steering checkpoint before - * a request or continuation decision; policy may stop before another step. - * After turn close and its checkpoint, any remainder is queued for a later - * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. - * Idle steering falls back to a woken follow-up turn. - * @param content - the steering content blocks. - * @param options - source and attached contexts. - * @returns the accepted message's {@link AgentMessageId}. - */ - steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-step', wakeup: true }) - } - - /** - * Append detached model-facing context without running the model — the - * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins - * at the current log position unless the current tool batch is executing; - * then it waits FIFO until that batch settles and drains before turn close - * even when interrupted. Idle injection uses a one-shot turn and durability - * checkpoint. Disposal awaits idle checkpoints; flush failures report through - * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. - * @param content - the injected context content blocks. - * @param options - source and durable model-hidden meta. - * @returns the accepted message's {@link AgentMessageId}. - */ - inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-step', wakeup: false }) - } + whenIdle(): Promise } declare module 'cordis' { @@ -303,8 +263,8 @@ declare module 'cordis' { */ 'agent/disposed'(this: Scoped, agent: Agent): void /** - * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does - * not enter `running` synchronously; drive lifecycle from this event. + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking + * delivery does not enter `running` synchronously; drive lifecycle from this event. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -316,7 +276,7 @@ declare module 'cordis' { * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record * is the eventual `user/message`/`steering/message`. Injection - * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. + * through `agent.inject()` bypasses the FIFOs and does not emit this. * @param agent - the agent whose inbox received the item. * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index aefa739dde..39a65fa996 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -3,32 +3,47 @@ import { Context, Service, symbols } from 'cordis' import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { - Agent, AgentMessageId, agentEvents, agentInterruptReasonOf, } from '@deepseek-ai/dsh-agent' -import type { AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import type { + Agent, + AgentCancelCause, + AgentFactory, + ContinuationStop, + CreateAgentOptions, + InjectOptions, + ResumeAgentOptions, + SendOptions, +} from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string, overrides: Partial = {}): Agent { const id = SessionId(rawId) - // Agent is an abstract class, so its alias methods live on the prototype and - // object spread would drop them; build the full literal and merge overrides. - return Object.assign(Object.create(Agent.prototype) as Agent, { + return { id, options: {}, session: new Session(id), status: 'idle', ctx: new Context(), send: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, ...overrides, - }) + } } describe('AgentRegistry', () => { + it('keeps concrete delivery routing out of public options', () => { + expectTypeOf<'target' extends keyof SendOptions ? true : false>().toEqualTypeOf() + expectTypeOf<'wakeup' extends keyof SendOptions ? true : false>().toEqualTypeOf() + expectTypeOf<'contexts' extends keyof InjectOptions ? true : false>().toEqualTypeOf() + }) + it('allows terminal stop policy to cooperate asynchronously with turn cancellation', () => { type TurnStopListener = Events['agent/turn-stop'] type AsyncTurnStopListener = () => Promise diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 8964d02df2..2611d7f3e6 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' @@ -26,7 +26,7 @@ function nextTurn(session: Session): number { } /** Append one idle injection using the public Agent contract's balanced shape. */ -function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void { +function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' } const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) @@ -49,7 +49,7 @@ function stubAgent(id: string): { agent: Agent; session: Session } { ctx: new Context(), get status() { return status }, send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') }, cancel() { status = 'idle' }, diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index d4994466f9..88f8ff202b 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -481,11 +481,11 @@ describe('same-session goal driving', () => { it('blocks the goal when a custom agent rejects the otherwise valid send', async () => { const test = await harness([]) - // inject shares send, so reject only the round send (a goal-sourced - // next-turn item), not the goal state-change injection that precedes it. + // Reject only the goal-sourced round send, not the state-change injection + // that precedes it. const realSend = test.agent.send.bind(test.agent) vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { - if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') { + if (options?.source?.kind === 'goal') { throw new Error('queue rejected') } return realSend(content, options) @@ -506,7 +506,7 @@ describe('same-session goal driving', () => { const test = await harness([]) const realSend = test.agent.send.bind(test.agent) vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { - if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') { + if (options?.source?.kind === 'goal') { test.ctx.goals.disarm(test.agent) throw new Error('queue rejected after disarm') } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 4f6e4e2cfc..5f037d3c6b 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import GoalService, { @@ -15,7 +15,7 @@ import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek- interface DeferredInjection { content: ContentBlock[] - options: AliasSendOptions | undefined + options: InjectOptions | undefined } interface StubAgent { @@ -33,7 +33,7 @@ function nextTurn(session: Session): number { } /** Mirror the public Agent.inject idle/open-turn contract for domain tests. */ -function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void { +function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' } const context = { content, @@ -65,7 +65,7 @@ function stubAgentForSession(session: Session): StubAgent { ctx: new Context(), get status() { return status }, send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { if (shouldDefer) deferred.push({ content, options }) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index f5bc34924a..a419cfd976 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { CallId } from '@deepseek-ai/dsh-llm' @@ -32,9 +32,9 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { get status() { return status }, ctx: new Context(), send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), - inject(content: ContentBlock[], options?: AliasSendOptions) { + inject(content: ContentBlock[], options?: InjectOptions) { const source = options?.source ?? { kind: 'plugin', plugin: '' } session.append('user/message', { content, diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 1112821841..c9aef7ce1f 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -244,7 +244,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -287,7 +287,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index f809393a7c..d0aa8db72e 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index a13861eb56..678403ef30 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -28,7 +28,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { status: 'idle', ctx: scopeFiber.ctx, send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index cf096ebb70..354ebce0ba 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index be32bfe2be..fce1d05bf7 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index 3478c313c1..51c06904d0 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -24,7 +24,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { status: 'idle' as const, ctx: scopeFiber.ctx, send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 243f492d2c..36fd40475c 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -155,7 +155,7 @@ export async function createTuiTestHarness { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) @@ -2523,7 +2523,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -2557,14 +2557,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -2594,7 +2594,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -2636,7 +2636,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index 9aedf69aca..7c61b9395d 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -30,49 +30,8 @@ function quote(value: string): string { } /** - * Reduce an exported class to its type shape: drop method/constructor bodies - * and property initializers so the catalog serves member signatures, not - * implementation. An abstract class (e.g. `Agent`) is a public type consumers - * program against, so it belongs in the type closure alongside interfaces. - */ -function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration { - const isNonPublic = (member: ts.ClassElement): boolean => - (ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some(m => - m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false - const members = node.members.flatMap((member): ts.ClassElement[] => { - // A model-facing type shape carries only the public surface — drop private, - // protected, and #private members, and strip every kept member's body. - if (isNonPublic(member) || (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name))) return [] - if (ts.isMethodDeclaration(member)) { - return [ts.factory.updateMethodDeclaration( - member, member.modifiers, member.asteriskToken, member.name, member.questionToken, - member.typeParameters, member.parameters, member.type, undefined)] - } - if (ts.isConstructorDeclaration(member)) { - return [ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined)] - } - if (ts.isGetAccessorDeclaration(member)) { - return [ts.factory.updateGetAccessorDeclaration( - member, member.modifiers, member.name, member.parameters, member.type, undefined)] - } - if (ts.isSetAccessorDeclaration(member)) { - return [ts.factory.updateSetAccessorDeclaration( - member, member.modifiers, member.name, member.parameters, undefined)] - } - if (ts.isPropertyDeclaration(member)) { - return [ts.factory.updatePropertyDeclaration( - member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined)] - } - return [member] - }) - return ts.factory.updateClassDeclaration( - node, node.modifiers, node.name, node.typeParameters, node.heritageClauses, members) -} - -/** - * Collect exported interface, type-alias, and (body-stripped) class shapes; - * omit names declared in multiple packages rather than risk serving the wrong - * package's shape. + * Collect exported interface and type shapes; omit names declared in multiple + * packages rather than risk serving the wrong package's shape. */ function collectTypeDecls(scanRoot: string = root): Map { const printer = ts.createPrinter({ removeComments: true }) @@ -82,16 +41,14 @@ function collectTypeDecls(scanRoot: string = root): Map { const abs = resolve(scanRoot, rel) const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true) for (const stmt of sf.statements) { - const named = ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isClassDeclaration(stmt) - if (!named || stmt.name === undefined) continue + if (!ts.isInterfaceDeclaration(stmt) && !ts.isTypeAliasDeclaration(stmt)) continue if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) continue const name = stmt.name.text if (decls.has(name)) { ambiguous.add(name) continue } - const emit = ts.isClassDeclaration(stmt) ? classShape(stmt) : stmt - const printed = printer.printNode(ts.EmitHint.Unspecified, emit, sf).replace(/\r/g, '') + const printed = printer.printNode(ts.EmitHint.Unspecified, stmt, sf).replace(/\r/g, '') decls.set(name, printed.length > MAX_DECL_CHARS ? `${printed.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */` : printed) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e57b170775..7aee2fc71b 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -66,11 +66,6 @@ "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "SendTarget", - "source": "packages/core/agent/src/types.ts" - }, { "doc": "docs/core-data-structures/core.md", "symbol": "SendOptions", @@ -78,7 +73,7 @@ }, { "doc": "docs/core-data-structures/core.md", - "symbol": "AliasSendOptions", + "symbol": "InjectOptions", "source": "packages/core/agent/src/types.ts" }, { From 218c98c2c232eee415043cc62009ebca63d12195 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 12:12:18 +0800 Subject: [PATCH 14/37] fix(goal): tolerate strict provider filler fields --- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 4 +- .../2026-07-19-model-facing-goal-tools.zh.md | 4 +- .../tool-schemas.expected.json | 8 +-- .../both-mode-turn/tool-schemas.expected.json | 8 +-- .../escalation-approved/session.jsonl | 4 +- .../escalation-rejected/session.jsonl | 4 +- .../tests/snapshots/fs-edit/session.jsonl | 2 +- .../snapshots/fs-policy-reject/session.jsonl | 4 +- .../fs-write-overwrite/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 4 +- .../lsp-definition/tool-schemas.expected.json | 8 +-- .../tool-schemas.expected.json | 16 ++--- .../tool-schemas.expected.json | 16 ++--- .../plan-mode/tool-schemas.expected.json | 16 ++--- .../pty-tools/tool-schemas.expected.json | 8 +-- .../skill-load/tool-schemas.expected.json | 8 +-- .../text-turn/tool-schemas.expected.json | 8 +-- .../tool-schemas.expected.json | 8 +-- packages/goal/tool-goal/README.md | 2 +- packages/goal/tool-goal/src/index.ts | 29 ++------- .../goal/tool-goal/tests/tool-goal.spec.ts | 61 +++++++++++-------- 22 files changed, 109 insertions(+), 119 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index e53c591aa5..b91a8758ca 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: 7cc3907d708115207e166455ea988120a03d768b -2026-07-19-model-facing-goal-tools.zh.md: 1a381160354d6a2a24f957f41bc9e375c1ab01ca +2026-07-19-model-facing-goal-tools.md: 7df04678ab8f78be71504b9a0d6e3a05391833e3 +2026-07-19-model-facing-goal-tools.zh.md: 102337fd57291102ce16a3978bafe9fcf62b9695 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index 7cc3907d70..7df04678ab 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -16,7 +16,7 @@ The surface also needs to preserve the separation between durable state and live ### Tools and model contract -`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. +`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; each action consumes only its fields and ignores the others, while `blocked` still requires a non-empty `blocked_reason` and persists it under the stable `model-reported` code. This tolerance is necessary because strict-schema providers may populate every declared property even though JSON Schema marks the action-specific fields optional. The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition. @@ -38,7 +38,7 @@ Complete and blocked accept either direct-human authority or the exact current g ## Testing -Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate. +Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, required blocker explanations, strict-provider filler fields, rearming after a session-start edge, authority-before-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 1a38116035..102337fd57 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -16,7 +16,7 @@ Status: implemented ### 工具与模型契约 -`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。 +`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;每个动作只消费自己的字段并忽略其他字段,而 `blocked` 仍要求非空的 `blocked_reason`,并以稳定代码 `model-reported` 持久化。尽管 JSON Schema 将动作专用字段标记为可选,严格模式提供方仍可能填充所有已声明属性,因此需要这种容忍行为。 提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、必需的阻塞说明、严格模式提供方填充字段、会话启动边沿后的重新激活、权限先于参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 ## 考虑过的替代方案 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 6b50a5d220..b3f147fdbf 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -470,7 +470,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -495,15 +495,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 7ceeec4042..0e2ebef051 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -413,7 +413,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -438,15 +438,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 4438550b97..6aa7fda0a8 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -132,8 +132,8 @@ {"type":"assistant/chunk","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} {"type":"tool/call","seq":132,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"efa76e4a-8e3e-4be0-9608-a68def7354b9","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"efa76e4a-8e3e-4be0-9608-a68def7354b9","outcome":"allowed-once"}} +{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","outcome":"allowed-once"}} {"type":"tool/result","seq":135,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":136,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":137,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index eb01e7443e..0d384a55ee 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -156,8 +156,8 @@ {"type":"assistant/chunk","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154],"surfaceOp":"append"} {"type":"tool/call","seq":156,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"e5cc84ef-8704-4cd9-8e47-897a0a51beb6","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"e5cc84ef-8704-4cd9-8e47-897a0a51beb6","outcome":"rejected"}} +{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","outcome":"rejected"}} {"type":"tool/result","seq":159,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[156],"surfaceOp":"append"} {"type":"step/end","seq":160,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":161,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index d0bd2d5bef..bcdd4c7b99 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -130,7 +130,7 @@ {"type":"assistant/chunk","seq":128,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":129,"time":1783352087469,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128],"surfaceOp":"append"} {"type":"tool/call","seq":130,"time":1783352087469,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} -{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} +{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /private/tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} {"type":"step/end","seq":132,"time":1783352087477,"data":{"turn":1,"step":2}} {"type":"step/start","seq":133,"time":1783352087477,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":134,"time":1783352088286,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 8f97feff04..25c57ee964 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -78,7 +78,7 @@ {"type":"assistant/chunk","seq":76,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":77,"time":1783611703972,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"} {"type":"tool/call","seq":78,"time":1783611703972,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} +{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} {"type":"step/end","seq":80,"time":1783611703978,"data":{"turn":1,"step":1}} {"type":"step/start","seq":81,"time":1783611703978,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":82,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -226,7 +226,7 @@ {"type":"assistant/chunk","seq":224,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":225,"time":1783611707097,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224],"surfaceOp":"append"} {"type":"tool/call","seq":226,"time":1783611707097,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} +{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} {"type":"step/end","seq":228,"time":1783611707114,"data":{"turn":1,"step":3}} {"type":"step/start","seq":229,"time":1783611707114,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":230,"time":1783611707747,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index 5a3fc5696b..f3db3493b5 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -115,7 +115,7 @@ {"type":"assistant/chunk","seq":113,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":114,"time":1783352094988,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."},{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113],"surfaceOp":"append"} {"type":"tool/call","seq":115,"time":1783352094988,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\nUpdated file\n"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} +{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"/private/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\nUpdated file\n"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} {"type":"step/end","seq":117,"time":1783352094995,"data":{"turn":1,"step":2}} {"type":"step/start","seq":118,"time":1783352094995,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":119,"time":1783352096090,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 888f2f5c13..810083f2a9 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -56,8 +56,8 @@ {"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"97616288-1a5e-4110-a75d-7616a24adcc4","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"97616288-1a5e-4110-a75d-7616a24adcc4","outcome":"rejected"}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","outcome":"rejected"}} {"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index b42a434388..8359d03017 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -434,7 +434,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -459,15 +459,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index ef40784fa5..dac439b700 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ @@ -947,7 +947,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -972,15 +972,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json index ef40784fa5..dac439b700 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ @@ -947,7 +947,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -972,15 +972,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json index ef40784fa5..dac439b700 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ @@ -947,7 +947,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -972,15 +972,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index 529b1419da..0ba75afbdc 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -526,7 +526,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -551,15 +551,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index b01e7683d1..f3739bbc42 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index b01e7683d1..f3739bbc42 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index b01e7683d1..f3739bbc42 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 3f286f8ec3..4fc7cb0ea9 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -6,7 +6,7 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal - `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation. - `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution. -- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. `edit` consumes replacement fields, `blocked` requires and persists `blocked_reason` with the stable code `model-reported`, and every action ignores fields it does not consume so providers that populate every schema property cannot prevent a valid transition. All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 009a00376f..5be69a9978 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -248,8 +248,9 @@ export function apply(ctx: Context, config: Config): void { name: 'update_goal', description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + 'top-level human request. During an automatic continuation of the current goal, complete ' - + 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains ' - + 'responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.', + + 'and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured ' + + 'minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and ' + + 'must explain it in blocked_reason.', parameters: { goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' }, revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' }, @@ -259,11 +260,11 @@ export function apply(ctx: Context, config: Config): void { enum: UPDATE_ACTIONS, description: 'edit | pause | resume | complete | blocked', }, - objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' }, - max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' }, + objective: { type: 'string', description: 'Replacement objective for action edit; ignored for other actions.' }, + max_goal_rounds: { type: 'number', description: 'Replacement round cap for action edit; ignored for other actions.' }, blocked_reason: { type: 'string', - description: 'Concrete blocking condition; required only with action blocked.', + description: 'Concrete blocking condition for action blocked; required for blocked, ignored for other actions.', }, }, output: GOAL_OUTPUT, @@ -276,21 +277,12 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) - if (args.blocked_reason !== undefined) { - throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') - } const goal = ctx.goals.edit(execution.agent, ref, replacements) observeMutation(terminalTurns, execution, false) return Promise.resolve(goalValue(goal)) } if (args.action === 'pause' || args.action === 'resume') { requireDirectHuman(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) { - throw new HarnessError( - 'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked', - 'GOAL_TOOL_INVALID_UPDATE', - ) - } const goal = args.action === 'pause' ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) @@ -298,15 +290,6 @@ export function apply(ctx: Context, config: Config): void { return Promise.resolve(goalValue(goal)) } const authority = completionAuthority(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined) { - throw new HarnessError( - 'objective and max_goal_rounds are valid only with action edit', - 'GOAL_TOOL_INVALID_UPDATE', - ) - } - if (args.action === 'complete' && args.blocked_reason !== undefined) { - throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') - } if (args.action === 'blocked' && (args.blocked_reason === undefined || args.blocked_reason.trim().length === 0)) { throw new HarnessError('blocked_reason is required with action blocked', 'GOAL_TOOL_INVALID_UPDATE') diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 7b2ccdc347..30270fd34d 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -377,26 +377,12 @@ describe('goal tool state transitions', () => { closeTurn(root, turn) }) - it('returns structured domain and conditional-argument failures', async () => { + it('returns structured domain and required-argument failures', async () => { const { ctx, root } = await harness() openTurn(root, { kind: 'user' }) const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent) expect(invalidCreate.error?.info?.code).toBe('GOAL_INVALID_OBJECTIVE') const created = ctx.goals.create(root.agent, { objective: 'valid' }) - const replacement = await execute(ctx, 'update_goal', { - goal_id: created.id, - revision: created.revision, - action: 'pause', - objective: 'not valid for pause', - }, root.agent) - expect(replacement.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') - const terminalUpdate = await execute(ctx, 'update_goal', { - goal_id: created.id, - revision: created.revision, - action: 'complete', - max_goal_rounds: 2, - }, root.agent) - expect(terminalUpdate.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const blockedWithoutReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'blocked', }, root.agent) @@ -405,24 +391,45 @@ describe('goal tool state transitions', () => { goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ', }, root.agent) expect(blockedWithEmptyReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') - const completeWithReason = await execute(ctx, 'update_goal', { - goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.', - }, root.agent) - expect(completeWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') - const editWithReason = await execute(ctx, 'update_goal', { - goal_id: created.id, - revision: created.revision, - action: 'edit', - objective: 'still valid', - blocked_reason: 'Not valid for edit.', - }, root.agent) - expect(editWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const malformedRef = await execute(ctx, 'update_goal', { goal_id: '', revision: 0, action: 'edit', objective: 'x', + max_goal_rounds: 0, blocked_reason: '', }, root.agent) expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') }) + it('ignores unused action fields emitted by strict-schema providers', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + let goal = ctx.goals.create(root.agent, { objective: 'valid' }) + const strictArgs = { + objective: '', + max_goal_rounds: 0, + blocked_reason: '', + } + + const paused = await execute(ctx, 'update_goal', { + goal_id: goal.id, revision: goal.revision, action: 'pause', ...strictArgs, + }, root.agent) + goal = ctx.goals.get(root.agent)! + expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'valid' }) + const resumed = await execute(ctx, 'update_goal', { + goal_id: goal.id, revision: goal.revision, action: 'resume', ...strictArgs, + }, root.agent) + goal = ctx.goals.get(root.agent)! + expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'valid' }) + const edited = await execute(ctx, 'update_goal', { + goal_id: goal.id, revision: goal.revision, action: 'edit', objective: 'edited', + max_goal_rounds: 7, blocked_reason: '', + }, root.agent) + goal = ctx.goals.get(root.agent)! + expect(resultGoal(edited)).toMatchObject({ objective: 'edited', maxGoalRounds: 7 }) + const complete = await execute(ctx, 'update_goal', { + goal_id: goal.id, revision: goal.revision, action: 'complete', ...strictArgs, + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete', objective: 'edited', maxGoalRounds: 7 }) + }) + it('allows exact goal rounds to complete but not edit or pause', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) From e27a1319ef2d1c60d0d25c96fe216b74919ae505 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 12:59:06 +0800 Subject: [PATCH 15/37] test(goal): align master snapshots --- docs/tool-catalog.md | 20 +++++++------------ .../system-prompt.expected.md | 8 ++++---- .../tests/snapshots/bash-spill/session.jsonl | 2 +- .../both-mode-turn/system-prompt.expected.md | 8 ++++---- .../code-mode-turn/system-prompt.expected.md | 8 ++++---- .../system-prompt.expected.md | 8 ++++---- .../escalation-approved/session.jsonl | 4 ++-- .../escalation-rejected/session.jsonl | 4 ++-- .../fs-escalation-approved/session.jsonl | 6 +++--- .../tests/snapshots/fs-write/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 4 ++-- .../tests/snapshots/plan-mode/session.jsonl | 4 ++-- .../goal/tool-goal/tests/tool-goal.spec.ts | 12 +++++++++-- 13 files changed, 46 insertions(+), 44 deletions(-) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 4f9de3644f..162232c35b 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -48,7 +48,6 @@ Ask the user a concise question when you need confirmation, a choice, or missing "description": "Questions to ask the user before continuing.", "items": { "type": "object", - "additionalProperties": true, "properties": { "id": { "type": "string", @@ -67,7 +66,6 @@ Ask the user a concise question when you need confirmation, a choice, or missing "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", "items": { "type": "object", - "additionalProperties": true, "properties": { "label": { "type": "string", @@ -231,7 +229,7 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor ### `cordis_mount` -Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. +Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. ```json { @@ -630,7 +628,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `update_goal` -Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. +Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. ```json { @@ -657,15 +655,15 @@ Update the exact current goal revision. edit, pause, and resume require a direct }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ @@ -898,7 +896,6 @@ Record and update a structured task list for the current work. Send the ENTIRE l "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, "properties": { "content": { "type": "string", @@ -940,7 +937,7 @@ Run a JavaScript workflow script that orchestrates subagents at scale. Use this The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: -- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. +- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. @@ -960,7 +957,6 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", - "additionalProperties": true, "properties": { "name": { "type": "string", @@ -979,7 +975,6 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", - "additionalProperties": true, "properties": { "title": { "type": "string", @@ -1011,8 +1006,7 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", - "additionalProperties": true + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 73c31413bf..cd179628cd 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -190,7 +190,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record)[]; } & Record; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -198,11 +198,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective; valid only with action edit. */ + /** Replacement objective for action edit; ignored for other actions. */ objective?: string; - /** Replacement cap; valid only with action edit. */ + /** Replacement round cap for action edit; ignored for other actions. */ max_goal_rounds?: number; - /** Concrete blocking condition; required only with action blocked. */ + /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ blocked_reason?: string; } & Record; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index eb4488858e..7493d1907c 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-fbfcf2f560a0/1bddd2b64176-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-02b64799b331/1bc5704fddf2-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 8744029272..ce858b897e 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -173,7 +173,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record)[]; } & Record; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -181,11 +181,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective; valid only with action edit. */ + /** Replacement objective for action edit; ignored for other actions. */ objective?: string; - /** Replacement cap; valid only with action edit. */ + /** Replacement round cap for action edit; ignored for other actions. */ max_goal_rounds?: number; - /** Concrete blocking condition; required only with action blocked. */ + /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ blocked_reason?: string; } & Record; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 8744029272..ce858b897e 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -173,7 +173,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record)[]; } & Record; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -181,11 +181,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective; valid only with action edit. */ + /** Replacement objective for action edit; ignored for other actions. */ objective?: string; - /** Replacement cap; valid only with action edit. */ + /** Replacement round cap for action edit; ignored for other actions. */ max_goal_rounds?: number; - /** Concrete blocking condition; required only with action blocked. */ + /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ blocked_reason?: string; } & Record; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index 8744029272..ce858b897e 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -173,7 +173,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record)[]; } & Record; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -181,11 +181,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective; valid only with action edit. */ + /** Replacement objective for action edit; ignored for other actions. */ objective?: string; - /** Replacement cap; valid only with action edit. */ + /** Replacement round cap for action edit; ignored for other actions. */ max_goal_rounds?: number; - /** Concrete blocking condition; required only with action blocked. */ + /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ blocked_reason?: string; } & Record; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 6aa7fda0a8..005b4f4c14 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -132,8 +132,8 @@ {"type":"assistant/chunk","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} {"type":"tool/call","seq":132,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","outcome":"allowed-once"}} +{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"bb75e939-221a-48b0-ad67-3da110ecbd31","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"bb75e939-221a-48b0-ad67-3da110ecbd31","outcome":"allowed-once"}} {"type":"tool/result","seq":135,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":136,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":137,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 0d384a55ee..c4f5658452 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -156,8 +156,8 @@ {"type":"assistant/chunk","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154],"surfaceOp":"append"} {"type":"tool/call","seq":156,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","outcome":"rejected"}} +{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"9a997191-81a0-4076-914b-255ebe3e6882","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"9a997191-81a0-4076-914b-255ebe3e6882","outcome":"rejected"}} {"type":"tool/result","seq":159,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[156],"surfaceOp":"append"} {"type":"step/end","seq":160,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":161,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 918b3ab4d5..67f3d15be0 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -90,9 +90,9 @@ {"type":"assistant/chunk","seq":88,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"} {"type":"tool/call","seq":90,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":91,"time":1784045703782,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":92,"time":1784045703786,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","outcome":"allowed-once"}} -{"type":"tool/result","seq":93,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[90],"surfaceOp":"append"} +{"type":"approval/asked","seq":91,"time":1784045703782,"data":{"id":"f6a28337-87da-4555-bc47-2b5354300cc4","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":92,"time":1784045703786,"data":{"id":"f6a28337-87da-4555-bc47-2b5354300cc4","outcome":"allowed-once"}} +{"type":"tool/result","seq":93,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[90],"surfaceOp":"append"} {"type":"step/end","seq":94,"time":1784045703798,"data":{"turn":1,"step":1}} {"type":"step/start","seq":95,"time":1784045703799,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":96,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index dbaaf8a8b6..46de5fa221 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -63,7 +63,7 @@ {"type":"assistant/chunk","seq":61,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":62,"time":1783352079888,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61],"surfaceOp":"append"} {"type":"tool/call","seq":63,"time":1783352079888,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} +{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/private/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 810083f2a9..baa85c9d77 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -56,8 +56,8 @@ {"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","outcome":"rejected"}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"c7877002-3d2e-432e-9624-0b9ef3624df3","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"c7877002-3d2e-432e-9624-0b9ef3624df3","outcome":"rejected"}} {"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl b/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl index 71a1189702..7c1b1a7574 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl +++ b/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl @@ -324,7 +324,7 @@ {"type":"assistant/chunk","seq":322,"time":1784525379646,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":323,"time":1784525379647,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The plan is approved. Now I need to apply exactly that one edit: change line 2 of notes.txt to say \"hello world\". I'll use the edit tool to replace the current line 2 content."},{"type":"tool-call","id":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3269,"outputTokens":134,"cacheReadTokens":0,"reasoningTokens":43}},"sourceEventSeqs":[232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322],"surfaceOp":"append"} {"type":"tool/call","seq":324,"time":1784525379647,"data":{"turn":1,"step":3,"callId":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}} -{"type":"tool/result","seq":325,"time":1784525379652,"data":{"turn":1,"step":3,"callId":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[324],"surfaceOp":"append"} +{"type":"tool/result","seq":325,"time":1784525379652,"data":{"turn":1,"step":3,"callId":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","content":[{"type":"text","text":"Error: edit requires reading \"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[324],"surfaceOp":"append"} {"type":"step/end","seq":326,"time":1784525379652,"data":{"turn":1,"step":3}} {"type":"step/start","seq":327,"time":1784525379655,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":328,"time":1784525380205,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -428,7 +428,7 @@ {"type":"assistant/chunk","seq":426,"time":1784525381745,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":427,"time":1784525381746,"data":{"turn":1,"step":5,"content":[{"type":"reasoning","text":"Now I can apply the edit."},{"type":"tool-call","id":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":169,"outputTokens":98,"cacheReadTokens":3456,"reasoningTokens":7}},"sourceEventSeqs":[372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426],"surfaceOp":"append"} {"type":"tool/call","seq":428,"time":1784525381746,"data":{"turn":1,"step":5,"callId":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}} -{"type":"tool/result","seq":429,"time":1784525381766,"data":{"turn":1,"step":5,"callId":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","content":[{"type":"text","text":"The file /var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"notes.txt","oldText":"project notes\n- the greeting message still says \"helo wrld\"","newText":"project notes\nhello world"}]}},"sourceEventSeqs":[428],"surfaceOp":"append"} +{"type":"tool/result","seq":429,"time":1784525381766,"data":{"turn":1,"step":5,"callId":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","content":[{"type":"text","text":"The file /private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"notes.txt","oldText":"project notes\n- the greeting message still says \"helo wrld\"","newText":"project notes\nhello world"}]}},"sourceEventSeqs":[428],"surfaceOp":"append"} {"type":"step/end","seq":430,"time":1784525381766,"data":{"turn":1,"step":5}} {"type":"step/start","seq":431,"time":1784525381768,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":432,"time":1784525382334,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 30270fd34d..c743f84903 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -409,10 +409,18 @@ describe('goal tool state transitions', () => { } const paused = await execute(ctx, 'update_goal', { - goal_id: goal.id, revision: goal.revision, action: 'pause', ...strictArgs, + goal_id: goal.id, + revision: goal.revision, + action: 'pause', + objective: 'ignored replacement', + max_goal_rounds: 1, + blocked_reason: 'ignored blocker', }, root.agent) goal = ctx.goals.get(root.agent)! - expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'valid' }) + expect(resultGoal(paused)).toMatchObject({ + phase: 'paused', objective: 'valid', maxGoalRounds: goal.maxGoalRounds, + }) + expect(goal.blockedReason).toBeUndefined() const resumed = await execute(ctx, 'update_goal', { goal_id: goal.id, revision: goal.revision, action: 'resume', ...strictArgs, }, root.agent) From 1595f4c851aa7da71ba5eaf7d88d70639acafe01 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:01:48 +0800 Subject: [PATCH 16/37] refactor(agent-loop): rename private input boundary --- ...7-24-intent-named-agent-delivery.i18n.yaml | 4 +-- .../2026-07-24-intent-named-agent-delivery.md | 2 +- ...26-07-24-intent-named-agent-delivery.zh.md | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 28 +++++++++---------- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml index 45b232613e..a6220691f1 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-intent-named-agent-delivery.md: df58435e5a59dbc5e3c2ba17c624728533ed3b96 -2026-07-24-intent-named-agent-delivery.zh.md: 78cf25870d3f220373692caf943870b920fb151e +2026-07-24-intent-named-agent-delivery.md: 62a05ed7601ca3dd2f05fae609341ab3fa37671a +2026-07-24-intent-named-agent-delivery.zh.md: e559b106ca54f901a18f2936142770d6306ef757 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md index df58435e5a..62a05ed760 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md @@ -21,7 +21,7 @@ Sharing helper implementations through an abstract `Agent` class also makes the `send`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` is absent: ordinary `send` already names the established common operation, and “follow-up” is false for a session's first message. -`ReactLoopAgent` resolves each public call into one module-private `ResolvedDelivery` and passes it to native-private `#acceptDelivery`. Every internal field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. Injection resolves contexts to the empty tuple. The private name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. +`ReactLoopAgent` resolves each public call into one module-private `ResolvedAgentInput` and passes it to native-private `#acceptInput`. Every internal field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. Injection resolves contexts to the empty tuple. The private name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. The target/wakeup matrix remains an implementation mechanism in `dsh-agent-loop`. It is not exported, protected, or represented by a base class. With one concrete adapter, a subclass delivery seam would be hypothetical; callers and tests use the same public `Agent` interface. diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md index 78cf25870d..e559b106ca 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md @@ -21,7 +21,7 @@ Status: implemented `send`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。接口不提供 `followup`:普通 `send` 已经为既有的常见操作命名,而「follow-up」不适用于会话的第一条消息。 -`ReactLoopAgent` 把每次公开调用解析为一个模块私有的 `ResolvedDelivery`,再将其传给原生私有的 `#acceptDelivery`。每个内部字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。注入会把上下文解析为空元组。这个私有名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 +`ReactLoopAgent` 把每次公开调用解析为一个模块私有的 `ResolvedAgentInput`,再将其传给原生私有的 `#acceptInput`。每个内部字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。注入会把上下文解析为空元组。这个私有名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 target/wakeup 矩阵仍是 `dsh-agent-loop` 中的实现机制。它不会导出,不是 protected 成员,也不由基类表示。只有一个具体适配器时,子类投递 seam 只是假想的;调用方和测试使用同一个公开 `Agent` 接口。 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 68677876f8..cc5adde987 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `ReactLoopAgent` adapter, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -`ReactLoopAgent` maps the public `send()`/`queue()`/`steer()`/`inject()` intents onto native-private `#acceptDelivery`. Each public method resolves every optional field before the private mechanism receives mandatory content, source, contexts, metadata, target, and wakeup facts; no configurable delivery primitive crosses the package seam. `send()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` accepts no attached contexts, bypasses both FIFOs, and appends durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. +`ReactLoopAgent` maps the public `send()`/`queue()`/`steer()`/`inject()` intents onto native-private `#acceptInput`. Each public method resolves every optional field before the private mechanism receives mandatory content, source, contexts, metadata, target, and wakeup facts; no configurable delivery primitive crosses the package seam. `send()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` accepts no attached contexts, bypasses both FIFOs, and appends durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 8dc6e95808..6d69d2a740 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -42,7 +42,7 @@ const bindContext = Symbol('dsh.agent-loop.bind-context') const publishAgent = Symbol('dsh.agent-loop.publish-agent') /** Fully resolved input accepted only by the concrete driver's private delivery mechanism. */ -type ResolvedDelivery = { +type ResolvedAgentInput = { content: ContentBlock[] source: MessageSource meta: JsonValue | undefined @@ -215,8 +215,8 @@ export class ReactLoopAgent implements Agent { * materialization reads every nested field once; deep freeze prevents later * caller mutation before an inbox or deferred-injection queue drains it. */ - private snapshotMessage(id: AgentMessageId, delivery: ResolvedDelivery): InboxMessage { - const { content, source, contexts, wakeup, meta } = delivery + private snapshotMessage(id: AgentMessageId, input: ResolvedAgentInput): InboxMessage { + const { content, source, contexts, wakeup, meta } = input const accepted = snapshotJsonValue({ id, content, source, contexts, wakeup, ...meta !== undefined ? { meta } : {}, @@ -241,17 +241,17 @@ export class ReactLoopAgent implements Agent { if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) } - /** Accept one fully resolved intent through the concrete driver's private routing matrix. */ - #acceptDelivery(delivery: ResolvedDelivery): AgentMessageId { + /** Accept one fully resolved agent input through the concrete driver's private routing matrix. */ + #acceptInput(input: ResolvedAgentInput): AgentMessageId { this.assertNotDisposed() const id = AgentMessageId(randomUUID()) - const { target, wakeup } = delivery + const { target, wakeup } = input // next-step/no-wakeup is injection: durable context without running the model. - if (target === 'next-step' && !wakeup) { this.injectContext(delivery); return id } + if (target === 'next-step' && !wakeup) { this.injectContext(input); return id } // next-step/wakeup is steering into the running turn; idle falls back to a // waking ordinary turn (there is no active turn to attach to). const steering = target === 'next-step' && this._status === 'running' - const accepted = this.snapshotMessage(id, delivery) + const accepted = this.snapshotMessage(id, input) if (steering) { this.#inbox.steer(accepted) } else { @@ -262,7 +262,7 @@ export class ReactLoopAgent implements Agent { } send(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.#acceptDelivery({ + return this.#acceptInput({ content, target: 'next-turn', wakeup: true, @@ -273,7 +273,7 @@ export class ReactLoopAgent implements Agent { } queue(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.#acceptDelivery({ + return this.#acceptInput({ content, target: 'next-turn', wakeup: false, @@ -284,7 +284,7 @@ export class ReactLoopAgent implements Agent { } steer(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.#acceptDelivery({ + return this.#acceptInput({ content, target: 'next-step', wakeup: true, @@ -295,7 +295,7 @@ export class ReactLoopAgent implements Agent { } inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId { - return this.#acceptDelivery({ + return this.#acceptInput({ content, target: 'next-step', wakeup: false, @@ -306,8 +306,8 @@ export class ReactLoopAgent implements Agent { } /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ - private injectContext(delivery: Extract): void { - const { content, source, meta } = delivery + private injectContext(input: Extract): void { + const { content, source, meta } = input const context = { content, source, From 2e1be7d4d76ac7106c8523ebc1b7844aa4363cc6 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 13:09:13 +0800 Subject: [PATCH 17/37] Revert "test(goal): align master snapshots" This reverts commit 028e63a9eee281a254f113f4721a67343e5521ca. --- docs/tool-catalog.md | 20 ++++++++++++------- .../system-prompt.expected.md | 8 ++++---- .../tests/snapshots/bash-spill/session.jsonl | 2 +- .../both-mode-turn/system-prompt.expected.md | 8 ++++---- .../code-mode-turn/system-prompt.expected.md | 8 ++++---- .../system-prompt.expected.md | 8 ++++---- .../escalation-approved/session.jsonl | 4 ++-- .../escalation-rejected/session.jsonl | 4 ++-- .../fs-escalation-approved/session.jsonl | 6 +++--- .../tests/snapshots/fs-write/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 4 ++-- .../tests/snapshots/plan-mode/session.jsonl | 4 ++-- .../goal/tool-goal/tests/tool-goal.spec.ts | 12 ++--------- 13 files changed, 44 insertions(+), 46 deletions(-) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 162232c35b..4f9de3644f 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -48,6 +48,7 @@ Ask the user a concise question when you need confirmation, a choice, or missing "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -66,6 +67,7 @@ Ask the user a concise question when you need confirmation, a choice, or missing "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", "items": { "type": "object", + "additionalProperties": true, "properties": { "label": { "type": "string", @@ -229,7 +231,7 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor ### `cordis_mount` -Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. +Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. ```json { @@ -628,7 +630,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `update_goal` -Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. +Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. ```json { @@ -655,15 +657,15 @@ Update the exact current goal revision. edit, pause, and resume require a direct }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ @@ -896,6 +898,7 @@ Record and update a structured task list for the current work. Send the ENTIRE l "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -937,7 +940,7 @@ Run a JavaScript workflow script that orchestrates subagents at scale. Use this The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: -- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. +- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. @@ -957,6 +960,7 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -975,6 +979,7 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -1006,7 +1011,8 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index cd179628cd..73c31413bf 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -190,7 +190,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record)[]; } & Record; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -198,11 +198,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective for action edit; ignored for other actions. */ + /** Replacement objective; valid only with action edit. */ objective?: string; - /** Replacement round cap for action edit; ignored for other actions. */ + /** Replacement cap; valid only with action edit. */ max_goal_rounds?: number; - /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ + /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; } & Record; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index 7493d1907c..eb4488858e 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-02b64799b331/1bc5704fddf2-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-fbfcf2f560a0/1bddd2b64176-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index ce858b897e..8744029272 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -173,7 +173,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record)[]; } & Record; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -181,11 +181,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective for action edit; ignored for other actions. */ + /** Replacement objective; valid only with action edit. */ objective?: string; - /** Replacement round cap for action edit; ignored for other actions. */ + /** Replacement cap; valid only with action edit. */ max_goal_rounds?: number; - /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ + /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; } & Record; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index ce858b897e..8744029272 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -173,7 +173,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record)[]; } & Record; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -181,11 +181,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective for action edit; ignored for other actions. */ + /** Replacement objective; valid only with action edit. */ objective?: string; - /** Replacement round cap for action edit; ignored for other actions. */ + /** Replacement cap; valid only with action edit. */ max_goal_rounds?: number; - /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ + /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; } & Record; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index ce858b897e..8744029272 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -173,7 +173,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record)[]; } & Record; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -181,11 +181,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective for action edit; ignored for other actions. */ + /** Replacement objective; valid only with action edit. */ objective?: string; - /** Replacement round cap for action edit; ignored for other actions. */ + /** Replacement cap; valid only with action edit. */ max_goal_rounds?: number; - /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ + /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; } & Record; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 005b4f4c14..6aa7fda0a8 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -132,8 +132,8 @@ {"type":"assistant/chunk","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} {"type":"tool/call","seq":132,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"bb75e939-221a-48b0-ad67-3da110ecbd31","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"bb75e939-221a-48b0-ad67-3da110ecbd31","outcome":"allowed-once"}} +{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","outcome":"allowed-once"}} {"type":"tool/result","seq":135,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":136,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":137,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index c4f5658452..0d384a55ee 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -156,8 +156,8 @@ {"type":"assistant/chunk","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154],"surfaceOp":"append"} {"type":"tool/call","seq":156,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"9a997191-81a0-4076-914b-255ebe3e6882","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"9a997191-81a0-4076-914b-255ebe3e6882","outcome":"rejected"}} +{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","outcome":"rejected"}} {"type":"tool/result","seq":159,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[156],"surfaceOp":"append"} {"type":"step/end","seq":160,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":161,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 67f3d15be0..918b3ab4d5 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -90,9 +90,9 @@ {"type":"assistant/chunk","seq":88,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"} {"type":"tool/call","seq":90,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":91,"time":1784045703782,"data":{"id":"f6a28337-87da-4555-bc47-2b5354300cc4","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":92,"time":1784045703786,"data":{"id":"f6a28337-87da-4555-bc47-2b5354300cc4","outcome":"allowed-once"}} -{"type":"tool/result","seq":93,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[90],"surfaceOp":"append"} +{"type":"approval/asked","seq":91,"time":1784045703782,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":92,"time":1784045703786,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","outcome":"allowed-once"}} +{"type":"tool/result","seq":93,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[90],"surfaceOp":"append"} {"type":"step/end","seq":94,"time":1784045703798,"data":{"turn":1,"step":1}} {"type":"step/start","seq":95,"time":1784045703799,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":96,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 46de5fa221..dbaaf8a8b6 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -63,7 +63,7 @@ {"type":"assistant/chunk","seq":61,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":62,"time":1783352079888,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61],"surfaceOp":"append"} {"type":"tool/call","seq":63,"time":1783352079888,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/private/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} +{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index baa85c9d77..810083f2a9 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -56,8 +56,8 @@ {"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"c7877002-3d2e-432e-9624-0b9ef3624df3","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"c7877002-3d2e-432e-9624-0b9ef3624df3","outcome":"rejected"}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","outcome":"rejected"}} {"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl b/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl index 7c1b1a7574..71a1189702 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl +++ b/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl @@ -324,7 +324,7 @@ {"type":"assistant/chunk","seq":322,"time":1784525379646,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":323,"time":1784525379647,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The plan is approved. Now I need to apply exactly that one edit: change line 2 of notes.txt to say \"hello world\". I'll use the edit tool to replace the current line 2 content."},{"type":"tool-call","id":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3269,"outputTokens":134,"cacheReadTokens":0,"reasoningTokens":43}},"sourceEventSeqs":[232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322],"surfaceOp":"append"} {"type":"tool/call","seq":324,"time":1784525379647,"data":{"turn":1,"step":3,"callId":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}} -{"type":"tool/result","seq":325,"time":1784525379652,"data":{"turn":1,"step":3,"callId":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","content":[{"type":"text","text":"Error: edit requires reading \"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[324],"surfaceOp":"append"} +{"type":"tool/result","seq":325,"time":1784525379652,"data":{"turn":1,"step":3,"callId":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[324],"surfaceOp":"append"} {"type":"step/end","seq":326,"time":1784525379652,"data":{"turn":1,"step":3}} {"type":"step/start","seq":327,"time":1784525379655,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":328,"time":1784525380205,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -428,7 +428,7 @@ {"type":"assistant/chunk","seq":426,"time":1784525381745,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":427,"time":1784525381746,"data":{"turn":1,"step":5,"content":[{"type":"reasoning","text":"Now I can apply the edit."},{"type":"tool-call","id":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":169,"outputTokens":98,"cacheReadTokens":3456,"reasoningTokens":7}},"sourceEventSeqs":[372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426],"surfaceOp":"append"} {"type":"tool/call","seq":428,"time":1784525381746,"data":{"turn":1,"step":5,"callId":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}} -{"type":"tool/result","seq":429,"time":1784525381766,"data":{"turn":1,"step":5,"callId":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","content":[{"type":"text","text":"The file /private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"notes.txt","oldText":"project notes\n- the greeting message still says \"helo wrld\"","newText":"project notes\nhello world"}]}},"sourceEventSeqs":[428],"surfaceOp":"append"} +{"type":"tool/result","seq":429,"time":1784525381766,"data":{"turn":1,"step":5,"callId":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","content":[{"type":"text","text":"The file /var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"notes.txt","oldText":"project notes\n- the greeting message still says \"helo wrld\"","newText":"project notes\nhello world"}]}},"sourceEventSeqs":[428],"surfaceOp":"append"} {"type":"step/end","seq":430,"time":1784525381766,"data":{"turn":1,"step":5}} {"type":"step/start","seq":431,"time":1784525381768,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":432,"time":1784525382334,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index c743f84903..30270fd34d 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -409,18 +409,10 @@ describe('goal tool state transitions', () => { } const paused = await execute(ctx, 'update_goal', { - goal_id: goal.id, - revision: goal.revision, - action: 'pause', - objective: 'ignored replacement', - max_goal_rounds: 1, - blocked_reason: 'ignored blocker', + goal_id: goal.id, revision: goal.revision, action: 'pause', ...strictArgs, }, root.agent) goal = ctx.goals.get(root.agent)! - expect(resultGoal(paused)).toMatchObject({ - phase: 'paused', objective: 'valid', maxGoalRounds: goal.maxGoalRounds, - }) - expect(goal.blockedReason).toBeUndefined() + expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'valid' }) const resumed = await execute(ctx, 'update_goal', { goal_id: goal.id, revision: goal.revision, action: 'resume', ...strictArgs, }, root.agent) From 0d413490021936d640320f076af0bfef248fa949 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 13:09:13 +0800 Subject: [PATCH 18/37] Revert "fix(goal): tolerate strict provider filler fields" This reverts commit 9a30dbb0f537f512180754c4778c15f9f94f1a95. --- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 4 +- .../2026-07-19-model-facing-goal-tools.zh.md | 4 +- .../tool-schemas.expected.json | 8 +-- .../both-mode-turn/tool-schemas.expected.json | 8 +-- .../escalation-approved/session.jsonl | 4 +- .../escalation-rejected/session.jsonl | 4 +- .../tests/snapshots/fs-edit/session.jsonl | 2 +- .../snapshots/fs-policy-reject/session.jsonl | 4 +- .../fs-write-overwrite/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 4 +- .../lsp-definition/tool-schemas.expected.json | 8 +-- .../tool-schemas.expected.json | 16 ++--- .../tool-schemas.expected.json | 16 ++--- .../plan-mode/tool-schemas.expected.json | 16 ++--- .../pty-tools/tool-schemas.expected.json | 8 +-- .../skill-load/tool-schemas.expected.json | 8 +-- .../text-turn/tool-schemas.expected.json | 8 +-- .../tool-schemas.expected.json | 8 +-- packages/goal/tool-goal/README.md | 2 +- packages/goal/tool-goal/src/index.ts | 29 +++++++-- .../goal/tool-goal/tests/tool-goal.spec.ts | 61 ++++++++----------- 22 files changed, 119 insertions(+), 109 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index b91a8758ca..e53c591aa5 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: 7df04678ab8f78be71504b9a0d6e3a05391833e3 -2026-07-19-model-facing-goal-tools.zh.md: 102337fd57291102ce16a3978bafe9fcf62b9695 +2026-07-19-model-facing-goal-tools.md: 7cc3907d708115207e166455ea988120a03d768b +2026-07-19-model-facing-goal-tools.zh.md: 1a381160354d6a2a24f957f41bc9e375c1ab01ca diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index 7df04678ab..7cc3907d70 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -16,7 +16,7 @@ The surface also needs to preserve the separation between durable state and live ### Tools and model contract -`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; each action consumes only its fields and ignores the others, while `blocked` still requires a non-empty `blocked_reason` and persists it under the stable `model-reported` code. This tolerance is necessary because strict-schema providers may populate every declared property even though JSON Schema marks the action-specific fields optional. +`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition. @@ -38,7 +38,7 @@ Complete and blocked accept either direct-human authority or the exact current g ## Testing -Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, required blocker explanations, strict-provider filler fields, rearming after a session-start edge, authority-before-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate. +Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 102337fd57..1a38116035 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -16,7 +16,7 @@ Status: implemented ### 工具与模型契约 -`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;每个动作只消费自己的字段并忽略其他字段,而 `blocked` 仍要求非空的 `blocked_reason`,并以稳定代码 `model-reported` 持久化。尽管 JSON Schema 将动作专用字段标记为可选,严格模式提供方仍可能填充所有已声明属性,因此需要这种容忍行为。 +`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。 提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、必需的阻塞说明、严格模式提供方填充字段、会话启动边沿后的重新激活、权限先于参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 ## 考虑过的替代方案 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index b3f147fdbf..6b50a5d220 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -470,7 +470,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -495,15 +495,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 0e2ebef051..7ceeec4042 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -413,7 +413,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -438,15 +438,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 6aa7fda0a8..4438550b97 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -132,8 +132,8 @@ {"type":"assistant/chunk","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} {"type":"tool/call","seq":132,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","outcome":"allowed-once"}} +{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"efa76e4a-8e3e-4be0-9608-a68def7354b9","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"efa76e4a-8e3e-4be0-9608-a68def7354b9","outcome":"allowed-once"}} {"type":"tool/result","seq":135,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":136,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":137,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 0d384a55ee..eb01e7443e 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -156,8 +156,8 @@ {"type":"assistant/chunk","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154],"surfaceOp":"append"} {"type":"tool/call","seq":156,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","outcome":"rejected"}} +{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"e5cc84ef-8704-4cd9-8e47-897a0a51beb6","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"e5cc84ef-8704-4cd9-8e47-897a0a51beb6","outcome":"rejected"}} {"type":"tool/result","seq":159,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[156],"surfaceOp":"append"} {"type":"step/end","seq":160,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":161,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index bcdd4c7b99..d0bd2d5bef 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -130,7 +130,7 @@ {"type":"assistant/chunk","seq":128,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":129,"time":1783352087469,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128],"surfaceOp":"append"} {"type":"tool/call","seq":130,"time":1783352087469,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} -{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /private/tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} +{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} {"type":"step/end","seq":132,"time":1783352087477,"data":{"turn":1,"step":2}} {"type":"step/start","seq":133,"time":1783352087477,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":134,"time":1783352088286,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 25c57ee964..8f97feff04 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -78,7 +78,7 @@ {"type":"assistant/chunk","seq":76,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":77,"time":1783611703972,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"} {"type":"tool/call","seq":78,"time":1783611703972,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} +{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} {"type":"step/end","seq":80,"time":1783611703978,"data":{"turn":1,"step":1}} {"type":"step/start","seq":81,"time":1783611703978,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":82,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -226,7 +226,7 @@ {"type":"assistant/chunk","seq":224,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":225,"time":1783611707097,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224],"surfaceOp":"append"} {"type":"tool/call","seq":226,"time":1783611707097,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} +{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} {"type":"step/end","seq":228,"time":1783611707114,"data":{"turn":1,"step":3}} {"type":"step/start","seq":229,"time":1783611707114,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":230,"time":1783611707747,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index f3db3493b5..5a3fc5696b 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -115,7 +115,7 @@ {"type":"assistant/chunk","seq":113,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":114,"time":1783352094988,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."},{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113],"surfaceOp":"append"} {"type":"tool/call","seq":115,"time":1783352094988,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"/private/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\nUpdated file\n"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} +{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\nUpdated file\n"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} {"type":"step/end","seq":117,"time":1783352094995,"data":{"turn":1,"step":2}} {"type":"step/start","seq":118,"time":1783352094995,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":119,"time":1783352096090,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 810083f2a9..888f2f5c13 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -56,8 +56,8 @@ {"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","outcome":"rejected"}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"97616288-1a5e-4110-a75d-7616a24adcc4","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"97616288-1a5e-4110-a75d-7616a24adcc4","outcome":"rejected"}} {"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 8359d03017..b42a434388 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -434,7 +434,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -459,15 +459,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index dac439b700..ef40784fa5 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ @@ -947,7 +947,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -972,15 +972,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json index dac439b700..ef40784fa5 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ @@ -947,7 +947,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -972,15 +972,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json index dac439b700..ef40784fa5 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ @@ -947,7 +947,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -972,15 +972,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index 0ba75afbdc..529b1419da 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -526,7 +526,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -551,15 +551,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index f3739bbc42..b01e7683d1 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index f3739bbc42..b01e7683d1 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index f3739bbc42..b01e7683d1 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 4fc7cb0ea9..3f286f8ec3 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -6,7 +6,7 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal - `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation. - `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution. -- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. `edit` consumes replacement fields, `blocked` requires and persists `blocked_reason` with the stable code `model-reported`, and every action ignores fields it does not consume so providers that populate every schema property cannot prevent a valid transition. +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 5be69a9978..009a00376f 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -248,9 +248,8 @@ export function apply(ctx: Context, config: Config): void { name: 'update_goal', description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + 'top-level human request. During an automatic continuation of the current goal, complete ' - + 'and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured ' - + 'minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and ' - + 'must explain it in blocked_reason.', + + 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains ' + + 'responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.', parameters: { goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' }, revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' }, @@ -260,11 +259,11 @@ export function apply(ctx: Context, config: Config): void { enum: UPDATE_ACTIONS, description: 'edit | pause | resume | complete | blocked', }, - objective: { type: 'string', description: 'Replacement objective for action edit; ignored for other actions.' }, - max_goal_rounds: { type: 'number', description: 'Replacement round cap for action edit; ignored for other actions.' }, + objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' }, + max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' }, blocked_reason: { type: 'string', - description: 'Concrete blocking condition for action blocked; required for blocked, ignored for other actions.', + description: 'Concrete blocking condition; required only with action blocked.', }, }, output: GOAL_OUTPUT, @@ -277,12 +276,21 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) + if (args.blocked_reason !== undefined) { + throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') + } const goal = ctx.goals.edit(execution.agent, ref, replacements) observeMutation(terminalTurns, execution, false) return Promise.resolve(goalValue(goal)) } if (args.action === 'pause' || args.action === 'resume') { requireDirectHuman(ctx, execution) + if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) { + throw new HarnessError( + 'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } const goal = args.action === 'pause' ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) @@ -290,6 +298,15 @@ export function apply(ctx: Context, config: Config): void { return Promise.resolve(goalValue(goal)) } const authority = completionAuthority(ctx, execution) + if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + throw new HarnessError( + 'objective and max_goal_rounds are valid only with action edit', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } + if (args.action === 'complete' && args.blocked_reason !== undefined) { + throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') + } if (args.action === 'blocked' && (args.blocked_reason === undefined || args.blocked_reason.trim().length === 0)) { throw new HarnessError('blocked_reason is required with action blocked', 'GOAL_TOOL_INVALID_UPDATE') diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 30270fd34d..7b2ccdc347 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -377,12 +377,26 @@ describe('goal tool state transitions', () => { closeTurn(root, turn) }) - it('returns structured domain and required-argument failures', async () => { + it('returns structured domain and conditional-argument failures', async () => { const { ctx, root } = await harness() openTurn(root, { kind: 'user' }) const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent) expect(invalidCreate.error?.info?.code).toBe('GOAL_INVALID_OBJECTIVE') const created = ctx.goals.create(root.agent, { objective: 'valid' }) + const replacement = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'pause', + objective: 'not valid for pause', + }, root.agent) + expect(replacement.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const terminalUpdate = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'complete', + max_goal_rounds: 2, + }, root.agent) + expect(terminalUpdate.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const blockedWithoutReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'blocked', }, root.agent) @@ -391,45 +405,24 @@ describe('goal tool state transitions', () => { goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ', }, root.agent) expect(blockedWithEmptyReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const completeWithReason = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.', + }, root.agent) + expect(completeWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const editWithReason = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'edit', + objective: 'still valid', + blocked_reason: 'Not valid for edit.', + }, root.agent) + expect(editWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const malformedRef = await execute(ctx, 'update_goal', { goal_id: '', revision: 0, action: 'edit', objective: 'x', - max_goal_rounds: 0, blocked_reason: '', }, root.agent) expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') }) - it('ignores unused action fields emitted by strict-schema providers', async () => { - const { ctx, root } = await harness() - openTurn(root, { kind: 'user' }) - let goal = ctx.goals.create(root.agent, { objective: 'valid' }) - const strictArgs = { - objective: '', - max_goal_rounds: 0, - blocked_reason: '', - } - - const paused = await execute(ctx, 'update_goal', { - goal_id: goal.id, revision: goal.revision, action: 'pause', ...strictArgs, - }, root.agent) - goal = ctx.goals.get(root.agent)! - expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'valid' }) - const resumed = await execute(ctx, 'update_goal', { - goal_id: goal.id, revision: goal.revision, action: 'resume', ...strictArgs, - }, root.agent) - goal = ctx.goals.get(root.agent)! - expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'valid' }) - const edited = await execute(ctx, 'update_goal', { - goal_id: goal.id, revision: goal.revision, action: 'edit', objective: 'edited', - max_goal_rounds: 7, blocked_reason: '', - }, root.agent) - goal = ctx.goals.get(root.agent)! - expect(resultGoal(edited)).toMatchObject({ objective: 'edited', maxGoalRounds: 7 }) - const complete = await execute(ctx, 'update_goal', { - goal_id: goal.id, revision: goal.revision, action: 'complete', ...strictArgs, - }, root.agent) - expect(resultGoal(complete)).toMatchObject({ phase: 'complete', objective: 'edited', maxGoalRounds: 7 }) - }) - it('allows exact goal rounds to complete but not edit or pause', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) From d436074cc23f2b304ae97198b968f25e7fb14c86 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 13:15:39 +0800 Subject: [PATCH 19/37] fix(goal): accept empty update fillers --- packages/goal/tool-goal/src/index.ts | 35 ++++++++++- .../goal/tool-goal/tests/tool-goal.spec.ts | 59 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 009a00376f..91ca254b2d 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -132,6 +132,32 @@ function resolveConfig(config: Config): ResolvedConfig { return { blockedAfterConsecutiveRounds: blockedAfter } } +/** Remove only empty provider fillers from fields unused by the selected action. */ +function normalizeUpdateArgs(args: Record): Record { + const normalized = { ...args } + const empty = (value: unknown): boolean => value === undefined || value === null || value === '' || value === 0 + const removeEmpty = (key: string): void => { + if (empty(normalized[key])) normalized[key] = undefined + } + switch (normalized['action']) { + case 'edit': + removeEmpty('blocked_reason') + break + case 'blocked': + removeEmpty('objective') + removeEmpty('max_goal_rounds') + break + case 'pause': + case 'resume': + case 'complete': + removeEmpty('objective') + removeEmpty('max_goal_rounds') + removeEmpty('blocked_reason') + break + } + return normalized +} + /** Build the exact compare-and-set ref from model arguments. */ function goalRef(goalId: string, revision: number): GoalRef { if (goalId.length === 0 || goalId !== goalId.trim() @@ -244,7 +270,7 @@ export function apply(ctx: Context, config: Config): void { presentCall: args => present('Create goal', 'other', args.objective), })) - ctx.tools.register(defineTool({ + const updateGoal = defineTool({ name: 'update_goal', description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + 'top-level human request. During an automatic continuation of the current goal, complete ' @@ -333,5 +359,10 @@ export function apply(ctx: Context, config: Config): void { 'other', args.blocked_reason ?? args.objective ?? args.goal_id, ), - })) + }) + // Object-literal execute methods do not use `this`; retaining the reference is safe. + // eslint-disable-next-line @typescript-eslint/unbound-method + const executeUpdate = updateGoal.execute + updateGoal.execute = (args, exec) => executeUpdate(normalizeUpdateArgs(args as Record), exec) + ctx.tools.register(updateGoal) } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 7b2ccdc347..a1261d3cb8 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -423,6 +423,65 @@ describe('goal tool state transitions', () => { expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') }) + it('accepts only empty fillers in fields unused by the selected action', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + let goal = ctx.goals.create(root.agent, { objective: 'valid' }) + + const edited = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'edit', + objective: 'edited', + blocked_reason: '', + }, root.agent) + expect(resultGoal(edited)).toMatchObject({ objective: 'edited' }) + goal = ctx.goals.get(root.agent)! + + const paused = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'pause', + objective: '', + max_goal_rounds: 0, + blocked_reason: null, + }, root.agent) + expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'edited' }) + goal = ctx.goals.get(root.agent)! + + const resumed = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'resume', + objective: null, + max_goal_rounds: '', + blocked_reason: '', + }, root.agent) + expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'edited' }) + goal = ctx.goals.get(root.agent)! + + const blocked = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'blocked', + objective: '', + max_goal_rounds: null, + blocked_reason: 'actual blocker', + }, root.agent) + expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked' }) + goal = ctx.goals.resume(root.agent, { id: goal.id, revision: goal.revision + 1 }) + + const complete = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'complete', + objective: '', + max_goal_rounds: 0, + blocked_reason: '', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete', objective: 'edited' }) + }) + it('allows exact goal rounds to complete but not edit or pause', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) From d94a4916f1c8a43a2c4b3c194bd183814edf52d0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 13:16:14 +0800 Subject: [PATCH 20/37] fix(goal): preserve normalized argument shape --- packages/goal/tool-goal/src/index.ts | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 91ca254b2d..a0df65a6ed 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -134,28 +134,12 @@ function resolveConfig(config: Config): ResolvedConfig { /** Remove only empty provider fillers from fields unused by the selected action. */ function normalizeUpdateArgs(args: Record): Record { - const normalized = { ...args } + const action = args['action'] const empty = (value: unknown): boolean => value === undefined || value === null || value === '' || value === 0 - const removeEmpty = (key: string): void => { - if (empty(normalized[key])) normalized[key] = undefined - } - switch (normalized['action']) { - case 'edit': - removeEmpty('blocked_reason') - break - case 'blocked': - removeEmpty('objective') - removeEmpty('max_goal_rounds') - break - case 'pause': - case 'resume': - case 'complete': - removeEmpty('objective') - removeEmpty('max_goal_rounds') - removeEmpty('blocked_reason') - break - } - return normalized + const unused = (key: string): boolean => key === 'blocked_reason' + ? action !== 'blocked' + : (key === 'objective' || key === 'max_goal_rounds') && action !== 'edit' + return Object.fromEntries(Object.entries(args).filter(([key, value]) => !unused(key) || !empty(value))) } /** Build the exact compare-and-set ref from model arguments. */ From f97f9bcf8a71fdc6c81cc6c3fc72a5042c6ce392 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 24 Jul 2026 13:23:10 +0800 Subject: [PATCH 21/37] chore(examples): declare @deepseek-ai/dsh-llm-pi-ai as an example dep pi-ai is the library-backed twin adapter the tui-agent README already points at ("swap one line to @deepseek-ai/dsh-llm-pi-ai"), and the supported entry point for third-party providers (Anthropic, Google, OpenRouter) mounted through the personal overlay under ~/.dsh. Making it a declared workspace dep of the examples umbrella means `pnpm install` resolves the symlink upstream so users configuring a third-party provider via `~/.dsh/config.yaml` don't have to patch `examples/package.json` locally (which their next git checkout would wipe). Placement matches the sibling llm-* cluster; workspace:* to match the other adapters. No cordis.yml or README changes: mounting pi-ai remains explicit and opt-in per the provider-routed-llm-adapters Agent Note. --- examples/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/examples/package.json b/examples/package.json index 1b4e28c4fd..bd87263340 100644 --- a/examples/package.json +++ b/examples/package.json @@ -26,6 +26,7 @@ "@deepseek-ai/dsh-jsonrpc": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*", "@deepseek-ai/dsh-lsp": "workspace:*", "@deepseek-ai/dsh-lsp-local": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e0d5cd798..aff769a93e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-pi-ai': + specifier: workspace:* + version: link:../packages/llm/llm-pi-ai '@deepseek-ai/dsh-llm-replay': specifier: workspace:* version: link:../packages/support/llm-replay From 3babb2cd423de28d409e545d8ef4822d5abb3881 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 13:30:19 +0800 Subject: [PATCH 22/37] feat(trajectory): implement trajectory step cell and layout with bilingual support - Added TrajectoryCell, TrajectoryGroupHeader, and TrajectoryTurn components for rendering trajectory steps and groups. - Introduced bilingual support with English and Chinese translations for trajectory notes. - Updated conversation session models to include timestamps for various message types. - Enhanced layout logic to handle expanded assistant blocks and tool results with duration metrics. - Added CSS styles for new components to ensure proper display and alignment. --- .../2026-07-23-trajectory-step-cell.i18n.yaml | 6 + .../2026-07-23-trajectory-step-cell.md | 35 ++ .../2026-07-23-trajectory-step-cell.zh.md | 35 ++ .../src/client/sessions/conversation.ts | 16 + .../src/client/sessions/fold-adapter.ts | 28 +- .../runtime/src/client/sessions/session.ts | 9 +- .../tests/chat-stats-bash-sample.spec.tsx | 7 +- .../tests/chat-tool-row.spec.tsx | 5 +- .../ui-conversation/tests/chat-view.spec.tsx | 9 +- .../tests/coverage-tails.spec.tsx | 6 +- .../tests/skeleton-branches.spec.tsx | 4 +- .../ui-conversation/tests/skeleton.spec.tsx | 9 +- .../ui-theme/src/styles/design-platform.css | 5 +- packages/client/ui-trajectory/README.md | 4 +- .../src/client/TrajectoryCell.module.css | 93 +++++ .../src/client/TrajectoryCell.tsx | 99 +++++ .../client/TrajectoryGroupHeader.module.css | 27 ++ .../src/client/TrajectoryGroupHeader.tsx | 26 ++ .../src/client/TrajectoryTurn.module.css | 16 + .../src/client/TrajectoryTurn.tsx | 26 ++ .../client/TrajectoryTurnHeader.module.css | 48 +++ .../src/client/TrajectoryTurnHeader.tsx | 30 ++ .../src/client/TrajectoryView.tsx | 50 ++- .../client/ui-trajectory/src/client/index.ts | 4 +- .../client/ui-trajectory/src/client/layout.ts | 374 ++++++++++++++++++ .../ui-trajectory/src/client/views.module.css | 18 +- .../client/ui-trajectory/tests/cell.spec.tsx | 87 ++++ .../ui-trajectory/tests/layout.spec.tsx | 143 +++++++ .../client/ui-trajectory/tests/views.spec.tsx | 41 +- 29 files changed, 1190 insertions(+), 70 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryCell.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryCell.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx create mode 100644 packages/client/ui-trajectory/src/client/layout.ts create mode 100644 packages/client/ui-trajectory/tests/cell.spec.tsx create mode 100644 packages/client/ui-trajectory/tests/layout.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml new file mode 100644 index 0000000000..fb39ae1301 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-trajectory-step-cell.md: edf31dcc72baa980caf0aa90fb8d5ec53d44346d +2026-07-23-trajectory-step-cell.zh.md: dbe813d48c3f3ac1c0926e45137624d758109c55 diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md new file mode 100644 index 0000000000..42d896b81a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md @@ -0,0 +1,35 @@ +# Agent Note: Trajectory step cell and turn list chrome + +Status: implemented + +English | [中文](2026-07-23-trajectory-step-cell.zh.md) + +## Problem + +The trajectory tab needs a reusable step row and turn-list chrome that can show expanded assistant blocks, own-duration times, Message token columns, and in-flight work. Without folding session event times into conversation nodes and expanding blocks into cells, the UI cannot match the product chrome. + +## Decision + +[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) owns the presentational trajectory list chrome: + +- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 38px step row with kinds User / Message / Tool (no Think, Call, or Result rows). Reasoning blocks are skipped (no block-level clock). Each `tool-call` + paired `tool-result` folds into one Tool row (`name ·` truncated args) whose Time is `result.time − callTime` when both are known. Message rows carry Input/Output/Think token columns from `assistant.usage`. Own-duration Time uses `+Ns` / `+N.1s`, or `—` when absent. Selected state draws a 2px inset `--dsw-alias-brand-primary-new-colorprimary-new-color` ring (`selected` prop) and is not wired to chat selection. +- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — sticky Turn bar paints full-bleed `ghost-active-fill`; title/columns and the Message/Step body sit in a centered `max-width: 880px` lane. Cell trailing columns share the Turn header geometry (`320 = 4×71 + 3×12`); cells use pad 20/8. +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only, and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`). + +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time; Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration). + +## Alternatives considered + +**Keep a Think cell for reasoning blocks.** Rejected: a single `assistant/message.time` cannot yield Think own-duration without chunk-level clocks; omit the row rather than show `—`. + +**Keep separate Call and Result rows.** Rejected: Result had no own duration to show; one Tool row carries the call→result interval. + +**Cumulative elapsed from session/turn start.** Rejected; the Time column is each row's own duration. + +**Hang usage on the first expanded row.** Rejected; usage attaches to Message only. + +**Show in-flight tool durations via Date.now().** Deferred; in-flight Time stays `—`. + +## Consequences + +The Trajectory tab can render expanded finalized and in-flight rows with own-duration times once fold emits `time`. Behavior-shaped coverage lives in `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`. Chat selection deep-links and finer block-level clocks remain deferred. diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md new file mode 100644 index 0000000000..c6bcde7deb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Trajectory 步骤单元格与轮次列表 chrome + +Status: implemented + +[English](2026-07-23-trajectory-step-cell.md) | 中文 + +## Problem + +trajectory 标签页需要可复用的步骤行与轮次列表 chrome,以展示展开后的 assistant 块、自身耗时、Message token 列,以及进行中的工作。若不将会话事件时间折叠进会话节点,并将块展开为单元格,UI 就无法对齐产品 chrome。 + +## Decision + +[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) 拥有展示型 trajectory 列表 chrome: + +- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 高 38px 的步骤行,类型为 User / Message / Tool(无 Think、Call、Result 行)。reasoning 块跳过(无块级时钟)。每对 `tool-call` + `tool-result` 折成一行 Tool(`name ·` 加截断参数),Time 在两端皆知时为 `result.time − callTime`。Message 行携带来自 `assistant.usage` 的 Input/Output/Think token 列。自身耗时 Time 使用 `+Ns` / `+N.1s`,缺失时为 `—`。选中态绘制 2px 内嵌的 `--dsw-alias-brand-primary-new-colorprimary-new-color` 环(`selected` prop),且未接线到 chat 选中。 +- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — 粘性 Turn 条背景通栏铺 `ghost-active-fill`;标题/列标与 Message/Step 主体落在居中的 `max-width: 880px` 内容道。单元格右侧列与 Turn 标头共用几何(`320 = 4×71 + 3×12`);cell pad 20/8。 +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上,并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。 + +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间;Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。 + +## Alternatives considered + +**为 reasoning 块保留 Think 单元格。** 否决:单条 `assistant/message.time` 无法给出 Think 自身耗时(除非上 chunk 级时钟);与其显示 `—`,不如省略该行。 + +**保留分开的 Call 与 Result 行。** 否决:Result 没有可展示的自身耗时;一行 Tool 承载 call→result 区间。 + +**自会话/轮次起点累计耗时。** 否决;Time 列是每行自身的耗时。 + +**将用量挂在展开后的第一行。** 否决;用量仅附着于 Message。 + +**用 Date.now() 显示进行中工具的耗时。** 延后;进行中的 Time 保持为 `—`。 + +## Consequences + +一旦 fold 发出 `time`,Trajectory 标签页即可渲染带自身耗时的已定稿与进行中展开行。行为导向的覆盖位于 `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`。chat 选中深链与更细的块级时钟仍延后。 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 1e06fad70d..08b80f2a26 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -42,6 +42,8 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock { export interface UserMessageNode { kind: 'user' seq: number + /** Unix epoch ms from the source session event. */ + time: number content: readonly ContentBlock[] source: unknown } @@ -50,6 +52,8 @@ export interface UserMessageNode { export interface AssistantMessageNode { kind: 'assistant' seq: number + /** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */ + time: number turn: number step: number blocks: readonly AssistantBlock[] @@ -63,6 +67,8 @@ export interface AssistantMessageNode { export interface SteeringMessageNode { kind: 'steering' seq: number + /** Unix epoch ms from the source session event. */ + time: number turn: number content: readonly ContentBlock[] source: unknown @@ -72,6 +78,8 @@ export interface SteeringMessageNode { export interface ContextMessageNode { kind: 'context' seq: number + /** Unix epoch ms from the source session event. */ + time: number content: readonly ContentBlock[] source: unknown meta?: unknown @@ -81,9 +89,13 @@ export interface ContextMessageNode { export interface ToolResultNode { kind: 'tool-result' seq: number + /** Unix epoch ms from the tool/result session event. */ + time: number callId: string /** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */ call: { name: string; argsRaw: string } | null + /** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */ + callTime: number | null content: readonly ContentBlock[] isError: boolean error?: { name: string; code: string } @@ -98,6 +110,8 @@ export interface ToolResultNode { export interface UnknownSurfaceNode { kind: 'unknown' seq: number + /** Unix epoch ms from the source session event when known. */ + time: number type: string data: unknown } @@ -118,6 +132,8 @@ export interface RunningToolCall { argsRaw: string turn: number step: number + /** Unix epoch ms when the tool/call event was logged. */ + time: number /** Host-computed render intent riding the tool/call frame; null = generic JSON card. */ callView: ToolCallView | null } diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index ccb48a0161..0342bc3c85 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -18,6 +18,8 @@ export interface CallIndexEntry { argsRaw: string turn: number step: number + /** Unix epoch ms of the tool/call event. */ + time: number /** Wire view riding the tool/call (envelope-level; never inside the event). */ callView: ToolCallView | null } @@ -38,24 +40,34 @@ function materializeNode( ): ConversationNode { switch (event.type) { case 'user/message': - return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source } + return { + kind: 'user', seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, + } case 'assistant/message': return { - kind: 'assistant', seq: event.seq, turn: event.data.turn, step: event.data.step, + kind: 'assistant', seq: event.seq, time: event.time, + turn: event.data.turn, step: event.data.step, blocks: toAssistantBlocks(event.data.content), usage: event.data.usage, } case 'steering/message': - return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source } + return { + kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, + content: event.data.content, source: event.data.source, + } case 'context/message': return { - kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source, + kind: 'context', seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, meta: event.data.meta, } case 'tool/result': { const call = callIndex.get(String(event.data.callId)) return { - kind: 'tool-result', seq: event.seq, callId: String(event.data.callId), + kind: 'tool-result', seq: event.seq, time: event.time, + callId: String(event.data.callId), call: call ? { name: call.name, argsRaw: call.argsRaw } : null, + callTime: call?.time ?? null, content: event.data.content, isError: event.data.isError, ...(event.data.error !== undefined ? { error: event.data.error } : {}), meta: event.data.meta, @@ -67,7 +79,10 @@ function materializeNode( surface-eligible types, and each has a case above; reachable only if core adds an eligible type. */ default: - return { kind: 'unknown', seq: event.seq, type: event.type, data: (event as { data?: unknown }).data } + return { + kind: 'unknown', seq: event.seq, time: event.time, + type: event.type, data: (event as { data?: unknown }).data, + } } } @@ -186,6 +201,7 @@ export class FoldAdapter { if (event.type !== 'tool/call') return this.callIdx.set(String(event.data.callId), { name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step, + time: event.time, callView: view?.for === 'call' ? view.view : null, }) // No backfill into already-materialized tool-result nodes for this callId diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index c394141d85..6b773e0903 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -434,7 +434,7 @@ export class Session implements ObservableSnapshot { case 'tool/call': { this.openCalls.set(String(event.data.callId), { callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments, - turn: event.data.turn, step: event.data.step, + turn: event.data.turn, step: event.data.step, time: event.time, callView: view?.for === 'call' ? view.view : null, }) this.callsRev++ @@ -455,7 +455,8 @@ export class Session implements ObservableSnapshot { if (visible) { // Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn. this.frozenNodes.push({ - kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step, + kind: 'assistant', seq: event.seq - 0.9, time: event.time, + turn: this.partial.turn, step: this.partial.step, blocks, interrupted: true, }) this.frozenRev++ @@ -469,8 +470,10 @@ export class Session implements ObservableSnapshot { this.callsRev++ // The spinner card becomes an interrupted terminal card (never vanishes mid-flow). this.frozenNodes.push({ - kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId, + kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time, + callId, call: { name: call.name, argsRaw: call.argsRaw }, + callTime: call.time, content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, callView: call.callView, resultView: null, }) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 2686a59ac2..4d3383b2d1 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -20,7 +20,7 @@ afterEach(cleanup) const SID = 's1' as SessionId const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({ - kind: 'assistant', seq, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }], + kind: 'assistant', seq, time: seq * 1_000, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }], ...(usage === undefined ? {} : { usage }), }) @@ -65,7 +65,7 @@ describe('deriveStats', () => { it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => { const tool: ToolResultNode = { - kind: 'tool-result', seq: 5, callId: 'c', call: null, content: [], + kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [], isError: false, callView: null, resultView: null, } const stats = deriveStats([tool, assistant(1, 1)]) @@ -112,8 +112,9 @@ describe('bash sample row', () => { const CHILD = 'child-1' as SessionId const result = (callId: string): ToolResultNode => ({ - kind: 'tool-result', seq: 3, callId, + kind: 'tool-result', seq: 3, time: 3_000, callId, call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' }, + callTime: 2_000, content: [], isError: false, callView: null, resultView: null, }) diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 828cf586fe..a221a4028b 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -12,12 +12,13 @@ import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/ const running = (over?: Partial): RunningToolCall => ({ callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}', - turn: 1, step: 1, callView: null, ...over, + turn: 1, step: 1, time: 1_000, callView: null, ...over, }) const result = (over?: Partial): ToolResultNode => ({ - kind: 'tool-result', seq: 10, callId: 'c1', + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' }, + callTime: 1_000, content: [], isError: false, callView: null, resultView: null, ...over, }) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 90c2f3090c..3f1db55199 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -54,18 +54,19 @@ function makeSource(init?: Partial) { } const user = (seq: number, text: string): UserMessageNode => ({ - kind: 'user', seq, content: [{ type: 'text', text }] as never, source: null, + kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null, }) const assistant = (seq: number, text: string): AssistantMessageNode => ({ - kind: 'assistant', seq, turn: 1, step: 1, blocks: [{ kind: 'text', text }], + kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }], }) const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ - kind: 'tool-result', seq, callId, + kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, + callTime: seq * 1_000 - 500, content: [], isError: false, callView: null, resultView: null, }) const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ - callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null, + callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, }) /** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */ diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 7f98cdd6ed..66ae3e802c 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -62,8 +62,9 @@ describe('tails', () => { it('a settled others-variant row renders the sparkle icon in the leading slot', () => { const settled: ToolResultNode = { - kind: 'tool-result', seq: 2, callId: 'c5', + kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5', call: { name: 'todo_write', argsRaw: '{"note":"x"}' }, + callTime: 1_000, content: [], isError: false, callView: null, resultView: null, } const props: ToolRowOwnerProps = { @@ -77,8 +78,9 @@ describe('tails', () => { it('BashRow shows the failed pill on error results (root session arm)', () => { const errorResult: ToolResultNode = { - kind: 'tool-result', seq: 1, callId: 'c1', + kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"boom"}' }, + callTime: 500, content: [], isError: true, callView: null, resultView: null, } // Root session (no parentId): the global arm renders, error pill visible. diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index b91fe229c3..10dba860c0 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -165,7 +165,7 @@ describe('DetailsPanel branches', () => { it('shows non-JSON args verbatim (streaming fragment path)', () => { const view = panel({ turnSeq: 1, callId: 'c1', toolName: 'bash' }, { - runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, callView: null }], + runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, time: 1_000, callView: null }], }) expect(view.getByText('{"cmd": tru')).toBeTruthy() }) @@ -176,7 +176,7 @@ describe('DetailsPanel branches', () => { }) it('snapshot updates re-run the material selector through the shallow equality arm', () => { - let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, callView: null }] } as ConversationSnapshot + let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, time: 1_000, callView: null }] } as ConversationSnapshot const subs = new Set<() => void>() const source = { getSnapshot: () => snap, diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index a4243c8cb7..c923c19269 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -234,10 +234,11 @@ describe('DetailsPanel', () => { it('renders the selected call args and result off the shared store; close fires the injected callback', () => { const { closeDetails } = benchDetails({ nodes: [{ - kind: 'tool-result', callId: 'c1', + kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"cmd":"ls"}' }, + callTime: 500, content: [{ type: 'text', text: 'file-a\nfile-b' }], - isError: false, + isError: false, callView: null, resultView: null, }], }, { turnSeq: 1, callId: 'c1' }) expect(screen.getByText('bash')).toBeTruthy() @@ -248,10 +249,10 @@ describe('DetailsPanel', () => { }) it('shows the empty hint without a selection and the running state for open calls', () => { - benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, null) + benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, null) expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy() cleanup() - benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, { turnSeq: 1, callId: 'c9' }) + benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, { turnSeq: 1, callId: 'c9' }) expect(screen.getByText('运行中…')).toBeTruthy() }) diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 96408ce4b9..e00ec415b7 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -169,6 +169,7 @@ body { --dsw-alias-border-l3: rgba(0, 0, 0, 0.12); --dsw-alias-border-l4: rgba(0, 0, 0, 0.16); --dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-1000); + --dsw-alias-brand-primary-new-colorprimary-new-color: rgb(65, 118, 230); --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-1000); --dsw-alias-brand-text: var(--dsw-static-neutral-bluish-1000); --dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-700); @@ -217,6 +218,7 @@ body { --dsw-alias-state-error-secondary: var(--dsw-static-red-400); --dsw-alias-state-success-primary: var(--dsw-static-green-500); --dsw-alias-state-success-secondary: var(--dsw-static-green-400); + --dsw-alias-state-success-tertiary: var(--dsw-static-green-100); --dsw-alias-state-warn-label: var(--dsw-static-amber-600); --dsw-alias-state-warn-primary: var(--dsw-static-amber-500); --dsw-alias-state-warn-secondary: var(--dsw-static-amber-400); @@ -257,11 +259,12 @@ body[data-ds-dark-theme] { --dsw-alias-border-l3: rgba(255, 255, 255, 0.16); --dsw-alias-border-l4: rgba(255, 255, 255, 0.2); --dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-50); + --dsw-alias-brand-primary-new-colorprimary-new-color: var(--dsw-static-deepseek-450); --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-50); --dsw-alias-brand-text: var(--dsw-static-neutral-bluish-50); --dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-50); --dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-750); - --dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-950); + --dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-850); --dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-800); --dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-600); --dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-750); diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index e3c2f6aade..f99a5c8386 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-trajectory -Trajectory/Waterfall placeholder views; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. +Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. ## Model Experience @@ -12,4 +12,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Both views are placeholders by charter** — coarse span derivation with no visual acceptance bar; the real implementations, anchor deep-linking, and span-click selection handoff are the P-III project. +- **In-flight Time stays blank** — `partial` / `runningCalls` rows render with `—` until a live clock policy lands; selected styling is local-only (not wired to chat details); anchor deep-linking remains deferred. diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css new file mode 100644 index 0000000000..1120fe2746 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css @@ -0,0 +1,93 @@ +/* Trajectory step cell — 38px row: index · kind tag · text · optional message + * metrics · elapsed time. */ + +.root { + display: flex; + align-items: center; + box-sizing: border-box; + height: 38px; + padding: 0 8px 0 20px; + gap: 24px; + border-radius: 8px; + border: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-3); + min-width: 0; +} + +.selected { + border-color: transparent; + box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary-new-colorprimary-new-color); +} + +.index { + flex: none; + width: 24px; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} + +.tagSlot { + flex: none; + width: 80px; + display: flex; + align-items: center; + min-width: 0; +} + +.tag { + display: inline-flex; + align-items: center; + box-sizing: border-box; + height: 22px; + max-width: 100%; + padding: 0 4px; + border-radius: 6px; + font: var(--dsw-font-xs-strong-13); + white-space: nowrap; +} + +.tagUser { + color: var(--dsw-alias-state-success-primary); + background: var(--dsw-alias-state-success-tertiary); +} + +.tagMessage { + color: var(--dsw-alias-brand-primary-new-colorprimary-new-color); + background: var(--dsw-specific-bubble); +} + +.tagTool { + color: var(--dsw-alias-state-warn-label); + background: var(--dsw-alias-state-warn-tertiary); +} + +.text { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-primary); +} + +/* Same column geometry as TrajectoryTurnHeader: 4×71 + 3×12 = 320. */ +.trailing { + flex: none; + display: flex; + align-items: center; + justify-content: flex-end; + width: 320px; + gap: 12px; + min-width: 0; +} + +.metric, +.time { + flex: none; + width: 71px; + text-align: left; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx new file mode 100644 index 0000000000..de99d027d8 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx @@ -0,0 +1,99 @@ +// TrajectoryCell: one step row in the trajectory list — index, kind tag, +// ellipsis text, optional Message token metrics, and own-duration time. + +import type { HTMLAttributes } from 'react' +import css from './TrajectoryCell.module.css' + +/** Closed set of trajectory step kinds (call+result fold into Tool; no Think). */ +export type TrajectoryCellKind = 'user' | 'message' | 'tool' + +/** Display label per kind (matches the design tags). */ +const KIND_LABEL: Record = { + user: 'User', + message: 'Message', + tool: 'Tool', +} + +const TAG_CLASS: Record = { + user: css.tagUser!, + message: css.tagMessage!, + tool: css.tagTool!, +} + +export interface TrajectoryCellProps extends HTMLAttributes { + /** 1-based step index shown as `#N`. */ + index: number + kind: TrajectoryCellKind + /** Single-line summary; CSS ellipsis when it overflows. */ + text: string + /** + * Own duration in seconds. `null` means no duration to show (em dash) — + * used for in-flight tools and tools missing callTime. + */ + timeSeconds: number | null + /** Message-only: prompt token count. */ + input?: number + /** Message-only: completion token count. */ + output?: number + /** Message-only: reasoning token count (usage column, not a Think cell). */ + think?: number + /** Selected: 2px inset brand-primary-new-color ring (not wired to chat selection yet). */ + selected?: boolean +} + +/** + * Format own-duration for the trailing time column: `—` when unknown, `+Ns` + * or `+N.1s` otherwise. + * @param seconds - duration seconds, or null when absent. + * @returns display string. + */ +export function formatElapsedSeconds(seconds: number | null): string { + if (seconds === null || !Number.isFinite(seconds)) return '—' + const rounded = Math.round(seconds * 10) / 10 + if (Number.isInteger(rounded)) return `+${rounded}s` + return `+${rounded.toFixed(1)}s` +} + +/** + * Render one trajectory step cell. + * @param props - index, kind, text, time, and optional Message metrics. + * @returns the cell element. + */ +export function TrajectoryCell({ + index, + kind, + text, + timeSeconds, + input, + output, + think, + selected = false, + className, + ...rest +}: TrajectoryCellProps) { + const rootClass = [ + css.root, + selected ? css.selected : undefined, + className, + ].filter((c): c is string => c !== undefined).join(' ') + const showMetrics = kind === 'message' + return ( +
+ #{index} + + {KIND_LABEL[kind]} + + {text} + + {showMetrics ? ( + <> + {input ?? ''} + {output ?? ''} + {think ?? ''} + + ) : null} + {formatElapsedSeconds(timeSeconds)} + +
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css new file mode 100644 index 0000000000..6de7074aaa --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css @@ -0,0 +1,27 @@ +/* Message / Step group title row inside a turn body. */ + +.root { + display: flex; + align-items: center; + box-sizing: border-box; + height: 36px; + padding: 0 20px; + gap: 24px; + min-width: 0; +} + +.title { + flex: none; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-primary); +} + +.description { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx new file mode 100644 index 0000000000..90252ce373 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx @@ -0,0 +1,26 @@ +// TrajectoryGroupHeader: "Message" or "Step N" row with optional description. + +import css from './TrajectoryGroupHeader.module.css' + +export interface TrajectoryGroupHeaderProps { + /** Group title (`Message`, `Step 1`, …). */ + title: string + /** Secondary summary (`49s`, `2.2s skill`, …). */ + description?: string +} + +/** + * Render a Message/Step group header inside a turn body. + * @param props - title and optional description. + * @returns the group header element. + */ +export function TrajectoryGroupHeader({ title, description }: TrajectoryGroupHeaderProps) { + return ( +
+ {title} + {description !== undefined && description !== '' + ? {description} + : null} +
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css new file mode 100644 index 0000000000..c1f243c8b9 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css @@ -0,0 +1,16 @@ +/* One turn block: sticky header + padded body with 10px item gap. */ + +.root { + width: 100%; +} + +.body { + display: flex; + flex-direction: column; + gap: 10px; + box-sizing: border-box; + width: 100%; + max-width: 880px; + margin: 0 auto; + padding: 8px 16px 22px; +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx new file mode 100644 index 0000000000..6ebce17731 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx @@ -0,0 +1,26 @@ +// TrajectoryTurn: sticky Turn header plus the padded Message/Step body. + +import type { ReactNode } from 'react' +import { TrajectoryTurnHeader } from './TrajectoryTurnHeader.tsx' +import css from './TrajectoryTurn.module.css' + +export interface TrajectoryTurnProps { + /** 1-based turn index for the sticky header. */ + turn: number + /** Message / Step headers and TrajectoryCell rows. */ + children?: ReactNode +} + +/** + * Render one turn section (sticky header + body). + * @param props - turn index and body children. + * @returns the turn section element. + */ +export function TrajectoryTurn({ turn, children }: TrajectoryTurnProps) { + return ( +
+ +
{children}
+
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css new file mode 100644 index 0000000000..4aaed68551 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css @@ -0,0 +1,48 @@ +/* Sticky turn bar: full-bleed ghost-active fill across the panel; title + + * metric labels sit in a centered 880 content lane (4×71 + 3×12 = 320). */ + +.root { + position: sticky; + top: 0; + z-index: 1; + box-sizing: border-box; + width: 100%; + height: 44px; + background: var(--dsw-alias-button-ghost-active-fill); +} + +.inner { + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + width: 100%; + max-width: 880px; + height: 100%; + margin: 0 auto; + padding: 0 16px; +} + +.title { + flex: none; + font: var(--dsw-font-xs-strong-13); + color: var(--dsw-alias-label-primary); +} + +.columns { + flex: none; + display: flex; + align-items: center; + width: 320px; + gap: 12px; + /* Match cell padding-right: 8 so Time lines up with the trailing lane. */ + margin-right: 8px; +} + +.column { + flex: none; + width: 71px; + text-align: left; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-secondary); +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx new file mode 100644 index 0000000000..ba54ed1c34 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx @@ -0,0 +1,30 @@ +// TrajectoryTurnHeader: sticky per-turn bar with Input/Output/Think/Time labels. + +import css from './TrajectoryTurnHeader.module.css' + +const COLUMN_LABELS = ['Input', 'Output', 'Think', 'Time'] as const + +export interface TrajectoryTurnHeaderProps { + /** 1-based turn index shown as `Turn N`. */ + turn: number +} + +/** + * Render the sticky turn header row. + * @param props.turn - turn index. + * @returns the sticky header element. + */ +export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) { + return ( +
+
+ Turn {turn} + +
+
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 0ccb298801..45277eb628 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,30 +1,40 @@ -// TrajectoryView: P-I placeholder body for the trajectory tab — span stats -// header over a per-turn span list with node-count weights (no timing data -// exists yet; deviation ledger #3 defers real rendering to P-III). +// TrajectoryView: sticky Turn sections with Message/Step groups and step cells. import { useMemo } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { deriveSpans } from './spans.ts' -import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx' +import { TrajectoryCell } from './TrajectoryCell.tsx' +import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx' +import { TrajectoryTurn } from './TrajectoryTurn.tsx' +import { deriveTrajectoryLayout } from './layout.ts' import css from './views.module.css' export function TrajectoryView({ useSession }: ConvViewProps) { const nodes = useSession((s) => s.nodes) - const spans = useMemo(() => deriveSpans(nodes), [nodes]) - if (spans.length === 0) return

暂无轨迹数据

+ const partial = useSession((s) => s.partial) + const runningCalls = useSession((s) => s.runningCalls) + const turns = useMemo( + () => deriveTrajectoryLayout({ nodes, partial, runningCalls }), + [nodes, partial, runningCalls], + ) + if (turns.length === 0) { + return

暂无轨迹数据

+ } return ( - <> - -
- {spans.map((span) => ( -
- turn {span.turn} - - {span.steps} steps · {span.calls} calls · {span.nodes} nodes - -
- ))} -
- +
+ {turns.map((turn) => ( + + {turn.groups.flatMap((group) => [ + , + ...group.cells.map((cell) => ( + + )), + ])} + + ))} +
) } diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 3979bfd91b..4a902fe9ff 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -24,8 +24,8 @@ export const inject = ['slots', 'conversation'] /** * Client plugin body: register the trajectory and waterfall view tabs. The * registrations ride the slot service's effect wrapper (plugin unload - * removes both tabs); the span stats header renders inside each view body - * (the chrome attachment mechanism retired with the view ring). + * removes both tabs). Trajectory owns its turn list in-body; Waterfall keeps + * the span stats header inside its body (chrome attachment retired). * @param ctx - client root context. */ export function apply(ctx: Context): void { diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts new file mode 100644 index 0000000000..03bfcb6893 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -0,0 +1,374 @@ +/** + * Trajectory list fold: expand assistant blocks, attach usage to Message, + * own-duration times, in-flight partial/runningCalls, and group descriptions. + */ +import type { + AssistantMessageNode, + ConversationSnapshot, + ToolResultNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { TrajectoryCellProps } from './TrajectoryCell.tsx' + +/** One Message or Step group inside a turn. */ +export interface TrajectoryGroupModel { + title: string + description?: string + cells: readonly TrajectoryCellProps[] +} + +/** One sticky-turn section. */ +export interface TrajectoryTurnModel { + turn: number + groups: readonly TrajectoryGroupModel[] +} + +/** Snapshot slice the trajectory view folds. */ +export interface TrajectoryLayoutInput { + nodes: ConversationSnapshot['nodes'] + partial: ConversationSnapshot['partial'] + runningCalls: ConversationSnapshot['runningCalls'] +} + +interface UsageLike { + inputTokens?: number + outputTokens?: number + reasoningTokens?: number +} + +/** Cell plus absolute ms for group wall-span descriptions. */ +interface LaidCell { + cell: TrajectoryCellProps + absTime: number | null + toolName?: string + callId?: string +} + +/** + * Fold a snapshot into turn → Message/Step groups with expanded cells. + * @param input - nodes plus in-flight partial/runningCalls. + * @returns turns ordered by first appearance. + */ +export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] { + const { nodes, partial, runningCalls } = input + const resultByCall = indexResults(nodes) + const turns = new Map }>() + let index = 0 + let prevAbsTime: number | null = null + + const bucket = (turn: number) => { + let entry = turns.get(turn) + if (entry === undefined) { + entry = { message: [], steps: new Map() } + turns.set(turn, entry) + } + return entry + } + + const pushMessage = (turn: number, laid: LaidCell) => { + bucket(turn).message.push(laid) + } + const pushStep = (turn: number, step: number, laid: LaidCell) => { + const steps = bucket(turn).steps + const list = steps.get(step) ?? [] + list.push(laid) + steps.set(step, list) + } + + for (const node of nodes) { + if (node.kind === 'user' || node.kind === 'steering') { + const turn = node.kind === 'steering' ? node.turn : 0 + pushMessage(turn, { + absTime: finiteTime(node.time), + cell: { + index: ++index, kind: 'user', text: summarizeContent(node.content), + timeSeconds: 0, + }, + }) + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + continue + } + if (node.kind === 'assistant') { + const laidList = expandAssistant(node, index + 1, prevAbsTime, resultByCall) + for (const laid of laidList) { + if (node.step > 0) pushStep(node.turn, node.step, laid) + else pushMessage(node.turn, laid) + } + const last = laidList[laidList.length - 1] + if (last !== undefined) index = last.cell.index + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + continue + } + if (node.kind === 'tool-result') { + if (!callEmittedInAssistant(nodes, node.callId)) { + const toolName = node.call?.name + pushStep(0, 1, { + absTime: finiteTime(node.callTime ?? node.time), + ...(toolName !== undefined ? { toolName } : {}), + callId: node.callId, + cell: { + index: ++index, + kind: 'tool', + text: node.call !== null + ? summarizeCall(node.call.name, node.call.argsRaw) + : summarizeResult(node), + timeSeconds: durationSeconds(node.time, node.callTime), + }, + }) + } + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + } + } + + if (partial !== null) { + const fake: AssistantMessageNode = { + kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0, + turn: partial.turn, step: partial.step, blocks: partial.blocks, + } + const laidList = expandAssistant(fake, index + 1, prevAbsTime, resultByCall, { streaming: true }) + for (const laid of laidList) { + if (partial.step > 0) pushStep(partial.turn, partial.step, laid) + else pushMessage(partial.turn, laid) + } + const last = laidList[laidList.length - 1] + if (last !== undefined) index = last.cell.index + } + + const seenCalls = collectCallIds(turns) + for (const call of runningCalls) { + if (seenCalls.has(call.callId)) continue + pushStep(call.turn, call.step > 0 ? call.step : 1, { + absTime: null, + toolName: call.name, + callId: call.callId, + cell: { + index: ++index, + kind: 'tool', + text: summarizeCall(call.name, call.argsRaw), + timeSeconds: null, + }, + }) + } + + const prologue = turns.get(0) + if (prologue !== undefined) { + turns.delete(0) + const emptyTurn = (): { message: LaidCell[]; steps: Map } => ({ + message: [], + steps: new Map(), + }) + const first = turns.get(1) ?? emptyTurn() + first.message = [...prologue.message, ...first.message] + for (const [step, cells] of prologue.steps) { + const existing = first.steps.get(step) ?? [] + first.steps.set(step, [...cells, ...existing]) + } + turns.set(1, first) + } + + return [...turns.entries()] + .sort(([a], [b]) => a - b) + .map(([turn, entry]) => toTurnModel(turn, entry)) +} + +function toTurnModel( + turn: number, + entry: { message: LaidCell[]; steps: Map }, +): TrajectoryTurnModel { + const groups: TrajectoryGroupModel[] = [] + if (entry.message.length > 0) { + const description = groupDescription(entry.message) + groups.push({ + title: 'Message', + ...(description !== undefined ? { description } : {}), + cells: entry.message.map(l => l.cell), + }) + } + for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) { + const laid = entry.steps.get(step) ?? [] + const description = groupDescription(laid) + groups.push({ + title: `Step ${step}`, + ...(description !== undefined ? { description } : {}), + cells: laid.map(l => l.cell), + }) + } + return { turn, groups } +} + +/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */ +function groupDescription(laid: readonly LaidCell[]): string | undefined { + const parts: string[] = [] + // Tool rows contribute start (absTime) and end (start + own duration) so a + // single Tool cell still spans call→result for the group wall clock. + const times: number[] = [] + for (const l of laid) { + if (l.absTime === null || !Number.isFinite(l.absTime)) continue + times.push(l.absTime) + if (l.cell.kind === 'tool' && l.cell.timeSeconds !== null && Number.isFinite(l.cell.timeSeconds)) { + times.push(l.absTime + l.cell.timeSeconds * 1000) + } + } + if (times.length >= 2) { + const span = formatGroupDuration((Math.max(...times) - Math.min(...times)) / 1000) + if (span !== undefined) parts.push(span) + } else if (times.length === 1) { + const own = laid.find(l => l.absTime === times[0])?.cell.timeSeconds + const span = own !== null && own !== undefined ? formatGroupDuration(own) : undefined + if (span !== undefined) parts.push(span) + } + const tools = new Map() + for (const l of laid) { + if (l.toolName === undefined || l.cell.kind !== 'tool') continue + tools.set(l.toolName, (tools.get(l.toolName) ?? 0) + 1) + } + for (const [name, count] of tools) { + parts.push(count > 1 ? `${name}×${count}` : name) + } + return parts.length === 0 ? undefined : parts.join(' ') +} + +function formatGroupDuration(seconds: number): string | undefined { + if (!Number.isFinite(seconds)) return undefined + const rounded = Math.round(seconds * 10) / 10 + if (Number.isInteger(rounded)) return `${rounded}s` + return `${rounded.toFixed(1)}s` +} + +/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */ +function durationSeconds(later: number, earlier: number | null): number | null { + if (earlier === null || !Number.isFinite(later) || !Number.isFinite(earlier)) return null + return Math.max(0, (later - earlier) / 1000) +} + +/** Epoch-ms usable as an absolute time, else null. */ +function finiteTime(time: number): number | null { + return Number.isFinite(time) ? time : null +} + +function expandAssistant( + node: AssistantMessageNode, + startIndex: number, + prevAbsTime: number | null, + results: Map, + opts?: { streaming?: boolean }, +): LaidCell[] { + const out: LaidCell[] = [] + let index = startIndex - 1 + const usage = node.usage as UsageLike | undefined + const streaming = opts?.streaming === true + const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime) + const nodeAbs = streaming ? null : finiteTime(node.time) + let usageAttached = false + + for (const block of node.blocks) { + // Reasoning blocks are skipped: no block-level clock, so no Think cell. + if (block.kind === 'reasoning') continue + if (block.kind === 'text') { + if (block.text === '' && streaming) continue + const cell: TrajectoryCellProps = { + index: ++index, kind: 'message', text: summarizeText(block.text), + timeSeconds: messageDuration, + } + if (!usageAttached && usage !== undefined) { + if (usage.inputTokens !== undefined) cell.input = usage.inputTokens + if (usage.outputTokens !== undefined) cell.output = usage.outputTokens + if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens + usageAttached = true + } + out.push({ absTime: nodeAbs, cell }) + continue + } + if (block.kind === 'tool-call') { + const result = results.get(block.callId) + const toolDuration = streaming || result === undefined + ? null + : durationSeconds(result.time, result.callTime) + const callAbs = streaming + ? null + : (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime) + ? result.callTime + : nodeAbs) + out.push({ + absTime: callAbs, + toolName: block.name, + callId: block.callId, + cell: { + index: ++index, kind: 'tool', + text: summarizeCall(block.name, block.argsRaw), + timeSeconds: toolDuration, + }, + }) + } + } + + if (out.length === 0 && !streaming) { + out.push({ + absTime: nodeAbs, + cell: { index: ++index, kind: 'message', text: '', timeSeconds: messageDuration }, + }) + } + return out +} + +function indexResults(nodes: ConversationSnapshot['nodes']): Map { + const map = new Map() + for (const node of nodes) { + if (node.kind === 'tool-result') map.set(node.callId, node) + } + return map +} + +function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: string): boolean { + for (const node of nodes) { + if (node.kind !== 'assistant') continue + if (node.blocks.some(b => b.kind === 'tool-call' && b.callId === callId)) return true + } + return false +} + +function collectCallIds( + turns: Map }>, +): Set { + const ids = new Set() + for (const entry of turns.values()) { + for (const laid of entry.message) { + if (laid.callId !== undefined) ids.add(laid.callId) + } + for (const list of entry.steps.values()) { + for (const laid of list) { + if (laid.callId !== undefined) ids.add(laid.callId) + } + } + } + return ids +} + +function summarizeCall(name: string, argsRaw: string): string { + const args = argsRaw.replace(/\s+/g, ' ').trim() + if (args === '') return name + const clipped = args.length > 72 ? `${args.slice(0, 71)}…` : args + return `${name} · ${clipped}` +} + +function summarizeResult(node: ToolResultNode): string { + if (node.isError) { + return node.error?.code ?? 'error' + } + for (const block of node.content) { + if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') { + return summarizeText(block.text) + } + } + return node.call?.name ?? node.callId +} + +function summarizeContent(content: readonly { type: string; text?: string }[]): string { + for (const block of content) { + if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text) + } + return '' +} + +function summarizeText(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 951a5e2705..d3089b3568 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -1,25 +1,34 @@ +/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge; + * cell content width is capped on the turn body (max 880). */ .root { - padding: 16px; overflow-y: auto; + height: 100%; + min-height: 0; + width: 100%; + box-sizing: border-box; color: var(--dsw-alias-label-primary); - font-size: 13px; + background: var(--dsw-specific-sidebar-fill); } .empty { + padding: 16px; color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); } +/* Waterfall placeholder rows (shared module). */ .row { display: flex; align-items: center; gap: 8px; - padding: 4px 0; + padding: 4px 16px; } .turnTag { flex: none; width: 64px; color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); } .bar { @@ -29,9 +38,10 @@ } .barCalls { - background: var(--dsw-alias-brand-primary); + background: var(--dsw-alias-brand-primary-new-colorprimary-new-color); } .meta { color: var(--dsw-alias-label-caption); + font: var(--dsw-font-xs-13); } diff --git a/packages/client/ui-trajectory/tests/cell.spec.tsx b/packages/client/ui-trajectory/tests/cell.spec.tsx new file mode 100644 index 0000000000..d9c9004622 --- /dev/null +++ b/packages/client/ui-trajectory/tests/cell.spec.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +/** + * TrajectoryCell presentation: kind tags, ellipsis-hosting text, Message + * metric columns, own-duration formatting, and selected ring. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { + formatElapsedSeconds, + TrajectoryCell, + type TrajectoryCellKind, +} from '../src/client/TrajectoryCell.tsx' + +afterEach(cleanup) + +describe('formatElapsedSeconds', () => { + it('formats known durations and uses an em dash when absent', () => { + expect(formatElapsedSeconds(null)).toBe('—') + expect(formatElapsedSeconds(235)).toBe('+235s') + expect(formatElapsedSeconds(235.0)).toBe('+235s') + expect(formatElapsedSeconds(235.2)).toBe('+235.2s') + expect(formatElapsedSeconds(235.25)).toBe('+235.3s') + expect(formatElapsedSeconds(0)).toBe('+0s') + expect(formatElapsedSeconds(Number.NaN)).toBe('—') + }) +}) + +describe('TrajectoryCell', () => { + it('renders index, kind tag, text, and time for a Tool row', () => { + render( + , + ) + expect(screen.getByText('#6')).toBeTruthy() + expect(screen.getByText('Tool')).toBeTruthy() + expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy() + expect(screen.getByText('+5s')).toBeTruthy() + }) + + it('Message rows expose Input / Output / Think metric columns before time', () => { + const { container } = render( + , + ) + expect(screen.getByText('Message')).toBeTruthy() + expect(screen.getByText('136')).toBeTruthy() + expect(screen.getByText('381')).toBeTruthy() + expect(screen.getByText('155')).toBeTruthy() + expect(screen.getByText('+235.2s')).toBeTruthy() + const texts = [...container.querySelectorAll('span')].map((el) => el.textContent) + expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381')) + expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155')) + expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s')) + }) + + it('selected marks the row for the brand-primary inset ring', () => { + const { container } = render( + , + ) + expect(container.firstElementChild?.getAttribute('data-selected')).toBe('true') + }) + + it.each([ + ['user', 'User'], + ['tool', 'Tool'], + ] as const)('kind %s shows the %s tag and no metric columns', (kind: TrajectoryCellKind, label: string) => { + const { container } = render( + , + ) + expect(screen.getByText(label)).toBeTruthy() + expect(container.querySelector('[data-kind]')?.getAttribute('data-kind')).toBe(kind) + expect(screen.queryByText('1')).toBeNull() + expect(screen.queryByText('2')).toBeNull() + expect(screen.queryByText('3')).toBeNull() + }) +}) diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx new file mode 100644 index 0000000000..4dc395221d --- /dev/null +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +/** + * Trajectory turn chrome and layout fold: expand blocks, usage on Message, + * tool own-duration, group wall-span descriptions, in-flight rows. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx' +import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx' +import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx' +import { deriveTrajectoryLayout } from '../src/client/layout.ts' + +afterEach(cleanup) + +describe('TrajectoryTurnHeader', () => { + it('renders Turn N and the four metric column labels', () => { + render() + expect(screen.getByText('Turn 1')).toBeTruthy() + expect(screen.getByText('Input')).toBeTruthy() + expect(screen.getByText('Output')).toBeTruthy() + expect(screen.getByText('Think')).toBeTruthy() + expect(screen.getByText('Time')).toBeTruthy() + }) +}) + +describe('TrajectoryGroupHeader', () => { + it('renders title and optional description', () => { + render() + expect(screen.getByText('Step 1')).toBeTruthy() + expect(screen.getByText('2.2s skill')).toBeTruthy() + }) + + it('omits the description node when absent', () => { + const { container } = render() + expect(screen.getByText('Message')).toBeTruthy() + expect(container.querySelectorAll('span')).toHaveLength(1) + }) +}) + +describe('TrajectoryTurn', () => { + it('wraps a sticky header and body children', () => { + render( + + + , + ) + expect(screen.getByText('Turn 3')).toBeTruthy() + expect(screen.getByText('Message')).toBeTruthy() + expect(screen.getByText('49s')).toBeTruthy() + }) +}) + +describe('deriveTrajectoryLayout', () => { + it('expands assistant blocks, hangs usage on Message, and folds call+result into Tool', () => { + const nodes = [ + { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hello' }], source: null }, + { + kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1, + blocks: [ + { kind: 'reasoning', text: 'thinking…' }, + { kind: 'text', text: 'I will run bash' }, + { kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls"}' }, + ], + usage: { inputTokens: 10, outputTokens: 20, reasoningTokens: 5 }, + }, + { + kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1', + call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200, + content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns).toHaveLength(1) + expect(turns[0]?.turn).toBe(1) + const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind)) + expect(kinds).toEqual(['user', 'message', 'tool']) + const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message') + expect(message).toMatchObject({ + input: 10, output: 20, think: 5, timeSeconds: 5, + }) + const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool') + expect(tool?.text).toBe('bash · {"command":"ls"}') + expect(tool?.timeSeconds).toBe(1.3) + }) + + it('adds runningCalls not already present and leaves their time blank', () => { + const turns = deriveTrajectoryLayout({ + nodes: [] as unknown as ConversationSnapshot['nodes'], + partial: null, + runningCalls: [{ + callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}', + turn: 1, step: 2, time: 9_000, callView: null, + }], + }) + expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2']) + expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({ + kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null, + }) + }) + + it('omits duration when node times are missing instead of rendering NaN', () => { + const nodes = [ + { kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null }, + { + kind: 'assistant', seq: 2, turn: 1, step: 1, + blocks: [ + { kind: 'reasoning', text: '…' }, + { kind: 'text', text: 'ok' }, + ], + usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 }, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? [] + expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull() + expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined() + }) + + it('builds a wall-span step description with a tool histogram', () => { + const nodes = [ + { + kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, + blocks: [ + { kind: 'tool-call', callId: 'a', name: 'bash', argsRaw: '{}' }, + { kind: 'tool-call', callId: 'b', name: 'bash', argsRaw: '{}' }, + ], + }, + { + kind: 'tool-result', seq: 2, time: 2_500, callId: 'a', + call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100, + content: [], isError: false, callView: null, resultView: null, + }, + { + kind: 'tool-result', seq: 3, time: 4_000, callId: 'b', + call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600, + content: [], isError: false, callView: null, resultView: null, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2') + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index a3ac84738e..4818e67db5 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -3,9 +3,9 @@ * View registration acceptance on the real framework stack: the plugin fiber * registers trajectory/waterfall into a real SlotsService view ring, tabs * switch inside ConversationRoot (renderSlot share driven by the same tab - * projection apply uses) without collapsing chat, the span stats header - * renders inside both view bodies, and fiber disposal removes both tabs. - * Span derivation edge cases ride along. + * projection apply uses) without collapsing chat, trajectory renders the + * turn-list chrome (no span stats bar), waterfall keeps in-body stats, and + * fiber disposal removes both tabs. Span derivation edge cases ride along. */ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -36,19 +36,26 @@ afterEach(cleanup) // The chat store persists under its declared key; clear so one case's active // view cannot rehydrate into the next. beforeEach(() => { - localStorage.clear() + // Node 22+ exposes an experimental localStorage global that is undefined + // without --localstorage-file; only clear when a real Storage is present. + if (typeof localStorage !== 'undefined') localStorage.clear() }) /** Node fixture: user prologue, two turns, one tool result inside turn 1. */ const NODES = [ - { kind: 'user', seq: 1, content: [], source: null }, - { kind: 'assistant', seq: 2, turn: 1, step: 1, blocks: [] }, - { kind: 'tool-result', seq: 3, callId: 'c1', call: null, content: [], isError: false, callView: null, resultView: null }, - { kind: 'assistant', seq: 4, turn: 2, step: 1, blocks: [] }, + { kind: 'user', seq: 1, time: 1_000, content: [], source: null }, + { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [] }, + { + kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: null, + content: [], isError: false, callView: null, resultView: null, + }, + { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] }, ] as unknown as ConversationSnapshot['nodes'] function fakeSession(nodes: ConversationSnapshot['nodes']) { - const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes }) + const store = createSnapshotStore({ + nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], + }) return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession } } @@ -99,8 +106,9 @@ function tabsOf(slots: SlotsService): ViewTab[] { /** Mount ConversationRoot over the ring ledger with an outlet-faithful renderSlot. */ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) { - const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({ + const sessionSnapshot = createSnapshotStore({ running: false, removed: false, promptError: null, nodes, + partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], }) const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession const chat = createChatStore().create() @@ -158,17 +166,20 @@ describe('plugin registration', () => { }) describe('tab switching in ConversationRoot', () => { - it('renders all three tabs, defaults to chat, and switches to trajectory with its header stats', async () => { + it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => { const b = await bench() mount(b.slots) expect(screen.getByTestId('chat-body')).toBeTruthy() expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall']) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - // In-body header stats over NODES: turns 0/1/2, 2 assistant steps, 1 tool call. - expect(screen.getByText('3 turns · 2 steps · 1 tool calls')).toBeTruthy() - expect(screen.getByText('turn 0')).toBeTruthy() - expect(screen.getByText('1 steps · 1 calls · 2 nodes')).toBeTruthy() + // Trajectory no longer mounts the span stats bar; the turn-list chrome owns the body. + expect(screen.queryByText(/turns ·/)).toBeNull() + expect(screen.getByText('Turn 1')).toBeTruthy() + expect(screen.getByText('Turn 2')).toBeTruthy() + expect(screen.getAllByText('Message').length).toBeGreaterThan(0) + expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0) + expect(screen.getAllByText('Input').length).toBeGreaterThan(0) expect(screen.queryByTestId('chat-body')).toBeNull() }) From a4b4a4c53df965982234a3b7f8b6e1cfb0b81c3f Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 13:36:27 +0800 Subject: [PATCH 23/37] fix: type check --- .../tests/chat-toolview-slot.spec.tsx | 3 ++- .../ui-conversation/tests/skeleton.spec.tsx | 23 +++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 69cb2cfa09..5d2b3408a2 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -31,8 +31,9 @@ beforeEach(() => { }) const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({ - kind: 'tool-result', seq, callId, + kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: args }, + callTime: seq * 1_000 - 500, content: [], isError: false, callView: null, resultView: null, }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index c923c19269..55cb46be2c 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -33,8 +33,27 @@ beforeEach(() => { /** Minimal conversation snapshot slice the skeleton reads. */ interface FakeSnapshot { - nodes: readonly { kind: string; callId?: string; call?: { name: string; argsRaw: string } | null; content?: readonly { type: string; text?: string }[]; isError?: boolean }[] - runningCalls: readonly { callId: string; name: string; argsRaw: string }[] + nodes: readonly { + kind: string + seq?: number + time?: number + callId?: string + call?: { name: string; argsRaw: string } | null + callTime?: number | null + content?: readonly { type: string; text?: string }[] + isError?: boolean + callView?: null + resultView?: null + }[] + runningCalls: readonly { + callId: string + name: string + argsRaw: string + turn?: number + step?: number + time?: number + callView?: null + }[] running: boolean removed: boolean promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null From d613fdb073b8235a63ddb8087bcc692daa7942ea Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 13:38:36 +0800 Subject: [PATCH 24/37] =?UTF-8?q?fix(agent-loop):=20third=20review=20pass?= =?UTF-8?q?=20=E2=80=94=20disposal=20discard=20ordering,=20flush=20guard,?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address a fresh-eye review of the disposal/injection fixes: - disposal now snapshots, clears, and marks disposed BEFORE emitting agent/inbox/discard (mirroring cancel's snapshot→clear→emit), so a re-entrant send/cancel from a discard listener throws 'disposed' or finds an empty inbox instead of leaking or double-discarding an id. The discard is unconditional (even on unpublished setup-rollback) to match send's unconditional enqueue, keeping every id balanced. - restore the turnRecorded guard on the idle-injection flush: a turn/start rejected pre-commit (append reentrancy / internal-dispatch veto) records nothing and owes no flush; the previous unconditional flush emitted a phantom-turn agent/error. The isTurnOpen/turnRecorded branches are reachable (reentrant inject from a session/event listener) and now covered by a regression test rather than v8-ignored. - rewrite the agent/inbox/discard event JSDoc to enumerate all three emitters (cancel, terminal turn-stop, disposal) — every enqueued id gets exactly one terminal dequeue-or-discard. Per-file coverage stays 100%. --- docs/cordis-catalog/events.md | 42 ++++++----- docs/event-producer-consumer.md | 26 +++---- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/core/agent-loop/src/agent.ts | 69 ++++++++++--------- packages/core/agent-loop/tests/agent.spec.ts | 32 +++++++++ packages/core/agent/src/types.ts | 14 ++-- 6 files changed, 117 insertions(+), 70 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 56c927e0db..05aea0ea7e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:503`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:507`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -121,15 +121,19 @@ Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/t ### `agent/inbox/discard` — emit -`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them. Fires once per effective clearing call with every discarded item, after `agent/cancel-requested` and before the abort. +Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` dropping pending steering (in-turn and on the post-turn late-steering drain); and disposal of any still-pending items (before `agent/status('disposed')`). Fires once per drop with every dropped item. ```ts cordis-catalog /** - * `cancel()` (without `keepInbox`) dropped pending inbox items without - * delivering them. Fires once per effective clearing call with every - * discarded item, after `agent/cancel-requested` and before the abort. - * @param agent - the agent whose inbox was cleared. - * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. + * Pending inbox items were dropped without delivering them, so every + * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR + * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after + * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` + * dropping pending steering (in-turn and on the post-turn late-steering + * drain); and disposal of any still-pending items (before + * `agent/status('disposed')`). Fires once per drop with every dropped item. + * @param agent - the agent whose inbox items were dropped. + * @param messages - the discarded messages in FIFO order (queued then steering); never empty. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ @@ -138,7 +142,7 @@ Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/t Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -184,7 +188,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:453`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:457`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -207,7 +211,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:384`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:388`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -234,7 +238,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:404`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -259,7 +263,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:414`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:418`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -285,7 +289,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:468`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:472`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -311,7 +315,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:429`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:433`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -333,7 +337,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:372`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -376,7 +380,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) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:441`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:445`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -398,7 +402,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:479`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:483`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -420,7 +424,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:490`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:494`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f0469118ba..94b204ad43 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,24 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:503`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:507`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:335`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:325`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:453`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:384`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:400`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:414`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:468`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:429`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:457`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:388`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:404`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:418`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:472`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:433`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:372`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:441`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:479`](../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), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:490`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:445`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:483`](../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), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:494`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 150c4880b3..1438096347 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -892,8 +892,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/discard', mode: 'emit', signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, messages: AgentMessage[]): void', - jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: '`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them.', + jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after\n * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop`\n * dropping pending steering (in-turn and on the post-turn late-steering\n * drain); and disposal of any still-pending items (before\n * `agent/status(\'disposed\')`). Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.', }, { name: 'agent/inbox/enqueue', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 9bc24de5da..b0a20a59e1 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -276,34 +276,40 @@ export class ReactLoopAgent extends Agent { } // No turn open: wrap the injection in a one-shot turn so every event stays // turn-enclosed (the durability/replay boundary is the turn). The payload is - // validated above, so both appends commit together; the finally still owes - // a turn/end (the turn-enclosure invariant) even if a post-commit observer - // throws after turn/start. + // validated above, but `Session.append` can still reject a turn/start + // pre-commit (append re-entrancy from a session/event listener, or an + // internal-dispatch veto), so the finally owes a turn/end only when + // turn/start actually committed. const turn = lastTurnNumber(this.session) + 1 try { this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) this.session.append('user/message', accepted, { surfaceOp: 'append' }) } finally { - // Close the turn if turn/start committed. With the payload validated up - // front both appends commit together, so the turn is always open here; - // the guard remains the turn-enclosure backstop. - /* v8 ignore next -- unopened turn is unreachable after up-front validation; kept as the enclosure backstop. */ + // Close the turn if turn/start made it into the log. A pre-commit veto + // must escape rather than being mistaken for a committed turn/end. if (isTurnOpen(this.session)) { this.session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - // Flush the one-shot turn through the store (the carrier owner), never a - // raw parallel. Keep inject() synchronous: report checkpoint failures live - // instead of rejecting the caller, and track the task so disposal drains it. - const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { - const rendered = errorChain(error) - const err = error instanceof Error ? error : new Error(rendered) - this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) - agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) - }) - this.pendingIdleFlushes.add(flush) - // Retire on either settlement path. - const retire = (): void => { this.pendingIdleFlushes.delete(flush) } - void flush.then(retire, retire) + // Checkpoint only an accepted one-shot turn: a turn/start rejected + // pre-commit recorded nothing, so it owes no flush (and a spurious flush + // would emit a phantom-turn agent/error). The payload is validated up + // front, so a committed turn/start is always followed by its user/message. + const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) + // Keep inject() synchronous: report checkpoint failures live instead of + // rejecting the caller, and track the task so disposal still drains it. + if (turnRecorded) { + // Through the store's flush (the carrier owner), never a raw parallel. + const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { + const rendered = errorChain(error) + const err = error instanceof Error ? error : new Error(rendered) + this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) + agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) + }) + this.pendingIdleFlushes.add(flush) + // Retire on either settlement path. + const retire = (): void => { this.pendingIdleFlushes.delete(flush) } + void flush.then(retire, retire) + } } } @@ -438,20 +444,21 @@ export class ReactLoopAgent extends Agent { */ private [stopDriver](): Promise | void { if (this._status !== 'disposed') { - // Discard any still-pending inbox items before disposal so every enqueued - // id gets a terminal lifecycle event; a disposed agent never dequeues - // them. Emitted while still published (before the status flip below), and - // only when there is a public lifecycle to observe it. - if (this.published) { - const discarded = this.#inbox.pending() - if (discarded.length > 0) { - const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) - agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) - } - } + // Snapshot any still-pending inbox items, then CLEAR and mark disposed + // BEFORE emitting the discard — mirroring cancel()'s snapshot→clear→emit + // order so a re-entrant send()/cancel() from a discard listener throws + // `disposed` (or finds an empty inbox) instead of leaking or double- + // discarding an id. `send()` emits enqueue unconditionally, so the discard + // is unconditional too (even on an unpublished rollback) to keep every + // enqueued id matched. + const discarded = this.#inbox.pending() this.#inbox.clear() this._status = 'disposed' this.resolveDisposed() + if (discarded.length > 0) { + const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) + } // Release whenIdle waiters BEFORE the (guarded) event emit — they are // internal state that must settle even if a listener throws below. Each // waiter chains `done`, so it resolves only once the loop actually exits. diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 3b8942d27c..c22919e066 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -216,6 +216,38 @@ describe('Agent', () => { expect(flushes).toBe(0) // nothing was appended, so no checkpoint }) + it('idle inject() re-entered from a session/event listener is rejected pre-commit and opens no turn', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + let flushes = 0 + ctx.on('session/flush', () => { flushes += 1 }) + // Injecting from inside a session/event listener re-enters Session.append, + // which rejects pre-commit — so turn/start never commits. The finally sees + // no open turn (closes nothing) and no recorded turn (no checkpoint), and + // the reentrant throw is contained by Session's post-commit dispatch. + // Fire on turn/end: at that instant the outer one-shot turn is closed (no + // turn open), so the reentrant inject takes the idle one-shot-turn path and + // its turn/start append re-enters Session and is rejected pre-commit. + let reentered = false + ctx.on('session/event', (_s, event) => { + if (!reentered && event.type === 'turn/end') { + reentered = true + agent.inject([{ type: 'text', text: 'reentrant' }], { source: { kind: 'plugin', plugin: 'p' } }) + } + }) + + agent.inject([{ type: 'text', text: 'outer' }], { source: { kind: 'plugin', plugin: 'p' } }) + // The outer injection's own one-shot turn is balanced; the reentrant one + // opened no turn (its turn/start was rejected pre-commit). + const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') + expect(turnStarts).toHaveLength(1) + const injected = agent.session.events.filter(e => e.type === 'user/message') + expect(injected).toHaveLength(1) // the reentrant user/message never committed + await new Promise(r => setTimeout(r, 10)) + expect(flushes).toBe(1) // only the outer accepted turn checkpointed + }) + it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index d542c39810..57a71c7772 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -334,11 +334,15 @@ declare module 'cordis' { */ 'agent/inbox/dequeue'(this: Scoped, agent: Agent, message: AgentMessage): void /** - * `cancel()` (without `keepInbox`) dropped pending inbox items without - * delivering them. Fires once per effective clearing call with every - * discarded item, after `agent/cancel-requested` and before the abort. - * @param agent - the agent whose inbox was cleared. - * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. + * Pending inbox items were dropped without delivering them, so every + * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR + * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after + * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` + * dropping pending steering (in-turn and on the post-turn late-steering + * drain); and disposal of any still-pending items (before + * `agent/status('disposed')`). Fires once per drop with every dropped item. + * @param agent - the agent whose inbox items were dropped. + * @param messages - the discarded messages in FIFO order (queued then steering); never empty. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ From d6d50deb248d6f9076f6c6067f2fb6023797e69a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:52:25 +0800 Subject: [PATCH 25/37] refactor(agent): expose resolved input acceptance --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 4 +- ...ied-send-and-coalesced-user-messages.zh.md | 4 +- ...7-24-intent-named-agent-delivery.i18n.yaml | 4 +- .../2026-07-24-intent-named-agent-delivery.md | 20 +++++---- ...26-07-24-intent-named-agent-delivery.zh.md | 20 +++++---- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/cordis-catalog/events.md | 43 +++++++++--------- docs/core-data-structures/core.md | 44 +++++++++++++++--- docs/event-producer-consumer.md | 36 +++++++-------- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../time-context/tests/time-context.spec.ts | 1 + .../tests/workspace-context.spec.ts | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 8 +++- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 28 ++++-------- packages/core/agent-loop/tests/agent.spec.ts | 34 ++++++++++++++ packages/core/agent/README.md | 5 ++- packages/core/agent/src/types.ts | 45 ++++++++++++++++--- packages/core/agent/tests/agent.spec.ts | 14 +++++- .../command-goal/tests/command-goal.spec.ts | 1 + packages/goal/goal/tests/goal.spec.ts | 1 + .../goal/tool-goal/tests/tool-goal.spec.ts | 1 + packages/pty/pty-local/tests/index.spec.ts | 6 +-- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 1 + .../tool-pty/tests/loader-composition.spec.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- packages/tasks/tasks/tests/tasks.spec.ts | 1 + packages/ui/tui/tests/harness.ts | 1 + packages/ui/tui/tests/tui.spec.ts | 12 ++--- scripts/type-equiv.manifest.json | 5 +++ 35 files changed, 244 insertions(+), 120 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index c5c9c83dc8..83f3b86de8 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: 9f6b29b4e1d90d75dbb88ab770c7393cdb859627 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 4469d211da23a24f61017770b29bc983a2abb5f1 +2026-07-22-unified-send-and-coalesced-user-messages.md: f9196b2170bdcdc45325abc1666d0c1545d475ac +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: b984d0706a6758e37df312d0b59bba3afb602258 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 9f6b29b4e1..f9196b2170 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -12,7 +12,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj ## Decision -**One private mechanism, four public intents.** The concrete loop resolves `send`, `queue`, `steer`, and `inject` into one private (`target` × `wakeup`) delivery mechanism. `send` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface exposes no routing fields or abstract base; the [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering. +**One acceptance mechanism, four intent helpers.** The concrete loop resolves `send`, `queue`, `steer`, and `inject` into one (`target` × `wakeup`) acceptance mechanism. `send` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface also exposes that mechanism as `acceptInput(ResolvedAgentInput)` for callers that already have fully resolved routing; every field is mandatory, and the discriminated input type excludes attached contexts from injection. The [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering. **inject keeps its mechanism.** `inject` appends durable model-facing context at the current log position (deferred behind an executing tool batch), or opens a one-shot `injection` turn when idle. It bypasses the FIFOs entirely, accepts no attached contexts, and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. @@ -43,4 +43,4 @@ Internally, `wakeup` is the “should the model run” signal, so the inbox dist - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on. - [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md) — the precedent for collapsing a mirrored live event. - [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md) — the cancel-cause signal `keepInbox` extends. -- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md) — the public interface and private routing placement. +- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md) — the public helpers and fully resolved acceptance interface. diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 4469d211da..b984d0706a 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -12,7 +12,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` ## 决策 -**一个私有机制,四种公开意图。** 具体循环把 `send`、`queue`、`steer` 和 `inject` 解析到同一个私有的(`target` × `wakeup`)投递机制中。`send` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口不暴露路由字段或抽象基类;取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。 +**一种接受机制,四种意图辅助方法。** 具体循环把 `send`、`queue`、`steer` 和 `inject` 解析到同一个(`target` × `wakeup`)接受机制中。`send` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口还将该机制暴露为 `acceptInput(ResolvedAgentInput)`;调用方若已持有完全解析的路由信息,即可使用该方法。使用时必须提供所有字段,可辨识输入类型也不允许注入携带附加上下文。取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。 **inject 保留其机制。** `inject` 在当前日志位置追加持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时开启一个一次性的 `injection` 轮次。它完全绕过 FIFO,不接受附加上下文,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 @@ -43,4 +43,4 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。 - [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md)——折叠镜像实时事件的先例。 - [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md)——`keepInbox` 所扩展的取消原因信号。 -- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md)——公开接口和私有路由的归属位置。 +- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md)——公开辅助方法以及接受完全解析输入的接口。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml index a6220691f1..69ec4e0895 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-intent-named-agent-delivery.md: 62a05ed7601ca3dd2f05fae609341ab3fa37671a -2026-07-24-intent-named-agent-delivery.zh.md: e559b106ca54f901a18f2936142770d6306ef757 +2026-07-24-intent-named-agent-delivery.md: 54ea6353a516830795d51c395706241a4570260d +2026-07-24-intent-named-agent-delivery.zh.md: 0d903a0d5dd383a154ade02ee8bed607d1f986b1 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md index 62a05ed760..54ea6353a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md @@ -6,13 +6,13 @@ English | [中文](2026-07-24-intent-named-agent-delivery.zh.md) ## Problem -A public `send(content, { target, wakeup, ... })` makes every caller learn the loop's routing matrix, its defaults, and the interaction between active-turn targeting and model activation. Optional routing fields also let advanced-looking calls silently become ordinary sends. The mechanism is useful inside the concrete driver, but exposing it gives plugins implementation knowledge without leverage. +A configurable `send(content, { target?, wakeup?, ... })` makes every caller learn the loop's routing matrix, its defaults, and the interaction between active-turn targeting and model activation. Optional routing fields also let advanced-looking calls silently become ordinary sends. Most callers have one semantic intent, while some adapters already possess exact routing facts and should not have to reverse-map them into a helper name. Sharing helper implementations through an abstract `Agent` class also makes the public seam nominal in practice. Object-literal adapters and tests must inherit prototype methods even though the package promises a swappable structural handle. The shared base exists only to forward fixed arguments, while the concrete loop remains the sole production adapter. ## Decision -`Agent` is a structural interface with four intent-named delivery methods: +`Agent` is a structural interface with four intent-named delivery helpers: - `send()` queues an ordinary turn and wakes the driver. - `queue()` queues an ordinary turn without waking an idle driver. @@ -21,15 +21,17 @@ Sharing helper implementations through an abstract `Agent` class also makes the `send`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` is absent: ordinary `send` already names the established common operation, and “follow-up” is false for a session's first message. -`ReactLoopAgent` resolves each public call into one module-private `ResolvedAgentInput` and passes it to native-private `#acceptInput`. Every internal field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. Injection resolves contexts to the empty tuple. The private name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. +`Agent` also exposes `acceptInput(ResolvedAgentInput)` for callers that already hold the complete route. Every field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. The discriminated union requires the empty context tuple for non-waking next-step injection. `ReactLoopAgent` implements this method once, and all four helpers resolve their defaults before delegating to it. The name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. -The target/wakeup matrix remains an implementation mechanism in `dsh-agent-loop`. It is not exported, protected, or represented by a base class. With one concrete adapter, a subclass delivery seam would be hypothetical; callers and tests use the same public `Agent` interface. +The target/wakeup matrix is an explicit advanced part of the structural `Agent` interface, not the ordinary helper options and not a base-class implementation seam. With one concrete adapter, a protected subclass seam would be hypothetical; callers and tests use the same public interface. ## Alternatives considered -**Keep a public configurable primitive.** Mandatory routing arguments remove defaulting mistakes but still require every caller to learn the matrix and allow invalid intent combinations such as attached contexts on injection. The concrete loop needs that flexibility; ordinary plugins do not. +**Keep the resolved primitive private.** This minimizes the public method count, but forces adapters that already hold exact target/wakeup facts to reverse-map them into helper calls and removes the reusable type for that resolved state. -**Rename the primitive to `sendInternal` or `addMessageAdvanced`.** Visibility belongs in the type and runtime boundary, not a warning in a public name. `addMessageAdvanced` is also inaccurate because acceptance may wake, queue, steer, inject, or later discard work. +**Use configurable `send` as the primitive.** Even mandatory routing arguments would make the common method carry advanced concerns. Keeping `send` semantic preserves its simple defaulted call shape; the separate discriminated input type rejects attached contexts on injection. + +**Rename the primitive to `sendInternal` or `addMessageAdvanced`.** A public method must not describe itself as internal. `addMessageAdvanced` is also inaccurate because acceptance may wake, queue, steer, inject, or later discard work; `acceptInput` names the synchronous guarantee instead. **Keep `followup` as the waking-turn helper.** Existing production callers use `send`, while `followup` has no TypeScript caller and does not describe the first ordinary message. Reusing `send` preserves the familiar intent without retaining an alias. @@ -37,13 +39,13 @@ The target/wakeup matrix remains an implementation mechanism in `dsh-agent-loop` ## Verification -Focused agent-loop coverage exercises waking sends, quiet queues, active and idle steering, injection, source/context snapshots, cancellation, and inbox lifecycle correlation through the public methods. Type-level coverage uses structural `Agent` fakes and rejects routing fields on `SendOptions` and contexts on `InjectOptions`. The keyless Cordis inspection snapshot pins the model-facing interface without a configurable delivery primitive or abstract-class implementation. +Focused agent-loop coverage exercises direct fully resolved acceptance, waking sends, quiet queues, active and idle steering, injection, source/context snapshots, cancellation, and inbox lifecycle correlation through the public methods. Type-level coverage uses structural `Agent` fakes, requires every `ResolvedAgentInput` field, requires empty contexts on its injection variant, and keeps routing fields out of `SendOptions`. The keyless Cordis inspection snapshot pins the structural interface without an abstract-class implementation. ## Consequences -Callers choose one verb instead of encoding two routing axes. The concrete loop retains one acceptance path and one ownership boundary, while the structural interface restores simple adapters and fakes. Adding a delivery intent now requires an explicit public name and mapping rather than another matrix combination. +Ordinary callers choose one verb instead of encoding two routing axes; advanced callers may submit the exact discriminated route. The concrete loop retains one acceptance path and one ownership boundary, while the structural interface preserves simple adapters and fakes. Adding a common delivery intent still requires an explicit public helper and mapping rather than another optional matrix combination. -Four short public methods duplicate a small amount of argument resolution inside the concrete adapter. That duplication is deliberate locality: defaults and routing stay beside the only implementation that owns them, and no generator support is needed merely to expose a class-shaped `Agent`. +The advanced method adds interface surface and requires structural fakes to implement it. In return, resolved routing has one typed representation, while helper defaults and mappings stay beside the only implementation that owns them. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md index e559b106ca..0d903a0d5d 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -公开的 `send(content, { target, wakeup, ... })` 会迫使每个调用方理解循环的路由矩阵、默认值,以及活跃轮次目标与模型激活之间的相互作用。可选路由字段还会让看似高级的调用悄然变成普通投递。该机制在具体驱动器内部很有用,但将其公开只会让插件背负实现知识,却得不到相应收益。 +可配置的 `send(content, { target?, wakeup?, ... })` 会迫使每个调用方理解循环的路由矩阵、默认值,以及活跃轮次目标与模型激活之间的相互作用。可选路由字段还会让看似高级的调用悄然变成普通投递。大多数调用方只有一种语义意图,而有些适配器已经持有确切的路由信息,不应再被迫将这些信息反向映射为某个辅助方法名称。 通过抽象 `Agent` 类共享辅助方法的实现,实际上也会让公开 seam 具有名义类型约束。对象字面量适配器和测试必须继承原型方法,尽管该包承诺提供一个可替换的结构化句柄。共享基类只负责转发固定参数,而具体循环仍是唯一的生产适配器。 ## 决策 -`Agent` 是一个结构化接口,提供四种按意图命名的投递方法: +`Agent` 是一个结构化接口,提供四种按意图命名的投递辅助方法: - `send()` 将一个普通轮次入队并唤醒驱动器。 - `queue()` 将一个普通轮次入队,但不唤醒空闲驱动器。 @@ -21,15 +21,17 @@ Status: implemented `send`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。接口不提供 `followup`:普通 `send` 已经为既有的常见操作命名,而「follow-up」不适用于会话的第一条消息。 -`ReactLoopAgent` 把每次公开调用解析为一个模块私有的 `ResolvedAgentInput`,再将其传给原生私有的 `#acceptInput`。每个内部字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。注入会把上下文解析为空元组。这个私有名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 +`Agent` 还公开 `acceptInput(ResolvedAgentInput)`,供已经持有完整路由的调用方使用。每个字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。对于目标为下一步且不触发唤醒的注入,可辨识联合类型要求上下文为空元组。`ReactLoopAgent` 统一实现这个方法;四个辅助方法都会先解析各自的默认值,再委托给它。这个名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 -target/wakeup 矩阵仍是 `dsh-agent-loop` 中的实现机制。它不会导出,不是 protected 成员,也不由基类表示。只有一个具体适配器时,子类投递 seam 只是假想的;调用方和测试使用同一个公开 `Agent` 接口。 +结构化 `Agent` 接口显式包含面向高级用法的 target/wakeup 矩阵;该矩阵不属于普通辅助方法的选项,也不是基类实现 seam。只有一个具体适配器时,protected 子类 seam 只是假想的;调用方和测试使用同一个公开接口。 ## 考虑过的替代方案 -**保留公开的可配置原语。** 强制提供路由参数可以消除默认值错误,但仍会要求每个调用方理解矩阵,也允许出现无效的意图组合,例如为注入附加上下文。具体循环需要这种灵活性;普通插件不需要。 +**让解析后的原语保持私有。** 这会把公开方法数量降到最低,但会迫使已经持有精确 target/wakeup 路由信息的适配器将其反向映射为辅助方法调用,也会移除表示该解析后状态的可复用类型。 -**把原语重命名为 `sendInternal` 或 `addMessageAdvanced`。** 可见性应由类型和运行时边界表达,而不是在公开名称中加入警告。`addMessageAdvanced` 也不准确,因为接受操作可能唤醒、排队、中途引导、注入,或在之后丢弃工作。 +**使用可配置的 `send` 作为原语。** 即使强制提供所有路由参数,也会让这个常用方法承载高级用法的复杂性。让 `send` 只表达语义意图,可以保留其带默认值的简单调用形式;单独的可辨识输入类型则会拒绝为注入附加上下文。 + +**把原语重命名为 `sendInternal` 或 `addMessageAdvanced`。** 公开方法不应在名称中把自己称为内部方法。`addMessageAdvanced` 也不准确,因为接受操作可能唤醒、排队、中途引导、注入,或在之后丢弃工作;`acceptInput` 描述的则是同步边界所保证的事实。 **保留 `followup` 作为唤醒轮次的辅助方法。** 现有生产调用方使用 `send`,而 `followup` 没有 TypeScript 调用方,也无法描述第一条普通消息。复用 `send` 可以保留熟悉的意图,同时不保留别名。 @@ -37,13 +39,13 @@ target/wakeup 矩阵仍是 `dsh-agent-loop` 中的实现机制。它不会导出 ## 验证 -聚焦的 agent-loop 覆盖率测试通过公开方法覆盖唤醒式投递、静默排队、活跃与空闲状态下的 steering(中途引导)、注入、来源与上下文快照、取消,以及 inbox 生命周期关联。类型级覆盖使用结构化 `Agent` 测试替身,并拒绝 `SendOptions` 上的路由字段和 `InjectOptions` 上的上下文。无密钥的 Cordis 检查快照固定了面向模型的接口,其中既没有可配置的投递原语,也没有抽象类实现。 +聚焦的 agent-loop 覆盖率测试通过公开方法覆盖直接接受完全解析的输入、唤醒式投递、静默排队、活跃与空闲状态下的 steering(中途引导)、注入、来源与上下文快照、取消,以及 inbox 生命周期关联。类型级覆盖使用结构化 `Agent` 测试替身,要求提供 `ResolvedAgentInput` 的每个字段,要求其注入变体的上下文为空,并确保 `SendOptions` 不包含路由字段。无密钥的 Cordis 检查快照固定了不采用抽象类实现的结构化接口。 ## 后果 -调用方选择一个动词即可,无需编码两条路由轴。具体循环保留一条接受路径和一个归属边界,而结构化接口重新支持简单的适配器和测试替身。新增一种投递意图时,需要显式给出公开名称和映射,而不是再增加一种矩阵组合。 +普通调用方选择一个动词即可,无需编码两条路由轴;高级调用方则可提交经过判别的精确路由。具体循环保留一条接受路径和一个归属边界,而结构化接口保留了对简单适配器和测试替身的支持。新增一种常见投递意图时,仍需要显式提供公开辅助方法及其映射,而不是再增加一种可选的矩阵组合。 -四个简短的公开方法会在具体适配器中重复少量参数解析。这项重复是为了让实现保持局部:默认值和路由都留在拥有它们的唯一实现旁边,无需仅为了暴露类形态的 `Agent` 而增加生成器支持。 +这个高级方法会扩大接口范围,并要求结构化测试替身实现它。作为回报,解析后的路由只有一种类型化表示,而辅助方法的默认值和映射仍留在拥有它们的唯一实现旁边。 ## 相关 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 7463516dba..3eec5bed79 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: edb42170fb508e522f2f26fd576902cc284e9eb4 -architecture.zh.md: e60fc2b2c61f139bbca59a3e9daa4703b2ce5976 +architecture.md: 02b5d854f87e06db6b90d6402ea630e196bc5a23 +architecture.zh.md: 57adb6a2959ee9336364b403f736f914b72b42f4 diff --git a/docs/architecture.md b/docs/architecture.md index edb42170fb..02b5d854f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,7 +131,7 @@ Session events are turn-enclosed; reload closes an interrupted tail with a synth ### Agent Handles -`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use intent-named `send()`, `queue()`, `steer()`, and `inject()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)); `cancel()` and `whenIdle()` control cancellation and quiescence. Caller, provider, and handle co-own one awaited teardown. +`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins normally use intent-named `send()`, `queue()`, `steer()`, and `inject()`; callers with fully resolved routing may use `acceptInput()` with every field mandatory ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control cancellation and quiescence. Caller, provider, and handle co-own one awaited teardown. ### Agent Scope diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index e60fc2b2c6..57adb6a295 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -131,7 +131,7 @@ forever: ### Agent 句柄 -`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用按意图命名的 `send()`、`queue()`、`steer()` 和 `inject()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md));`cancel()` 和 `whenIdle()` 控制取消和停稳过程。调用方、提供方和句柄共同拥有一项需等待完成的拆卸过程。 +`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件通常使用按意图命名的 `send()`、`queue()`、`steer()` 和 `inject()`;调用方若已持有完全解析的路由信息,可以使用 `acceptInput()`,其中每个字段均为必填项([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制取消和停稳过程。调用方、提供方和句柄共同拥有一项需等待完成的拆卸过程。 ### Agent 作用域 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index fa8494b14c..d5746ad12b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:294`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:463`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:494`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -138,19 +138,20 @@ Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/t Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:336`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit -A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` bypasses the FIFOs and does not emit this. +A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs and does not emit this. ```ts cordis-catalog /** * A detached, frozen item entered the agent's inbox (queued or steering * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record - * is the eventual `user/message`/`steering/message`. Injection - * through `agent.inject()` bypasses the FIFOs and does not emit this. + * is the eventual `user/message`/`steering/message`. Injection through + * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs + * and does not emit this. * @param agent - the agent whose inbox received the item. * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -161,7 +162,7 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -184,7 +185,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:413`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:444`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -207,7 +208,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:344`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:375`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -234,7 +235,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:391`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -259,7 +260,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:374`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:405`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -285,7 +286,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:428`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:459`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -311,7 +312,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:389`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -333,7 +334,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:328`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -353,7 +354,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking deliver Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -376,7 +377,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) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:401`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:432`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -398,7 +399,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:439`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:470`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -420,7 +421,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:450`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:481`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 85689a89d6..82c7ebc24d 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -388,6 +388,26 @@ interface InjectOptions { } ``` +The advanced acceptance form makes every default explicit and rules out attached contexts on injection: + +```ts type-equiv +/** + * Fully specified input for {@link Agent.acceptInput}. Unlike the intent-named + * helpers, this form applies no defaults: callers provide content, source, + * contexts, metadata (including explicit `undefined`), target, and wakeup. + * The union excludes attached contexts from non-waking next-step injection. + */ +type ResolvedAgentInput = { + content: ContentBlock[] + source: MessageSource + meta: JsonValue | undefined +} & ( + | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } + | { target: 'next-step'; wakeup: true; contexts: HookContext[] } + | { target: 'next-step'; wakeup: false; contexts: [] } +) +``` + FIFO delivery methods return an opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events. Injection returns an id but bypasses those events: ```ts type-equiv @@ -403,16 +423,17 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t ```ts type-equiv /** * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` - * is the value `send`, `queue`, or `steer` returned to the caller, stable across - * this message's enqueue, dequeue, and discard events. Source defaults are - * already applied, so these are the exact values the item was accepted with. + * is the value returned by the accepting helper or {@link Agent.acceptInput}, + * stable across this message's enqueue, dequeue, and discard events. Source + * defaults, when applicable, are already applied, so these are the exact values + * the item was accepted with. * `steering` is true for an item drained between steps; otherwise it is claimed * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable * model-hidden state that lands on the eventual `user/message`/ * `steering/message`, not live-event routing data. */ interface AgentMessage { - /** The id returned by the accepting `send`, `queue`, or `steer` call. */ + /** The id returned by the accepting helper or {@link Agent.acceptInput}. */ id: AgentMessageId content: ContentBlock[] source: MessageSource @@ -443,7 +464,7 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` -The structural `Agent` interface exposes four delivery intents. The concrete driver resolves them into a private routing mechanism rather than exporting the target/wakeup matrix. +The structural `Agent` interface exposes four intent helpers plus the fully resolved acceptance method. The concrete driver implements the matrix once, and each helper supplies its fixed routing and defaults. ```ts type-equiv /** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ @@ -507,6 +528,19 @@ interface Agent { */ inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId + /** + * Accept one fully specified input through the same snapshot and routing path + * as the four intent-named helpers. `next-turn` targets the ordinary FIFO; + * `next-step`/wakeup targets steering (falling back to an ordinary waking turn + * while idle); and `next-step` without wakeup injects durable context without + * running the model. Every field is mandatory and no source or routing default + * is applied. Invalid input throws synchronously before notification, enqueue, + * or append. + * @param input - the resolved content, attribution, context, metadata, and routing facts. + * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. + */ + acceptInput(input: ResolvedAgentInput): AgentMessageId + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5337683ccd..530d4b9b47 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,24 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:305`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:413`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:344`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:374`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:428`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:389`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:328`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:401`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:439`](../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), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:450`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:494`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:336`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:375`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:391`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:405`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:459`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:432`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:470`](../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), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:481`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index e9d139c8be..b95ab420af 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n acceptInput(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 1121d8e9cd..b82fa568f1 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n acceptInput(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 0f196a590a..2f9d679f21 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -52,6 +52,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 6b7da3b19c..7ce1c4da28 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -188,6 +188,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 446ded7d0e..efebae179e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -899,7 +899,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/enqueue', mode: 'emit', signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, message: AgentMessage): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * through `agent.inject()` bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection through\n * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs\n * and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', }, { @@ -1181,7 +1181,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n acceptInput(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n}', }, { name: 'AgentCancelCause', @@ -1715,6 +1715,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RequestHeaderReason', declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';', }, + { + name: 'ResolvedAgentInput', + declaration: 'export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n} & ({\n target: \'next-turn\';\n wakeup: boolean;\n contexts: HookContext[];\n} | {\n target: \'next-step\';\n wakeup: true;\n contexts: HookContext[];\n} | {\n target: \'next-step\';\n wakeup: false;\n contexts: [\n ];\n});', + }, { name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index cc5adde987..cdbd512d0d 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `ReactLoopAgent` adapter, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -`ReactLoopAgent` maps the public `send()`/`queue()`/`steer()`/`inject()` intents onto native-private `#acceptInput`. Each public method resolves every optional field before the private mechanism receives mandatory content, source, contexts, metadata, target, and wakeup facts; no configurable delivery primitive crosses the package seam. `send()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` accepts no attached contexts, bypasses both FIFOs, and appends durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. +`ReactLoopAgent.acceptInput()` implements the public fully resolved acceptance path. The `send()`/`queue()`/`steer()`/`inject()` helpers resolve every optional field before delegating to it; direct callers provide mandatory content, source, contexts, metadata, target, and wakeup facts through `ResolvedAgentInput`. `send()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` or equivalent `acceptInput()` routing enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` and non-waking next-step acceptance require an empty context tuple, bypass both FIFOs, and append durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 6d69d2a740..b42c3c9662 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -17,11 +17,12 @@ import type { CancelOptions, HookContext, InjectOptions, + ResolvedAgentInput, SendOptions, } from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { snapshotJsonValue, type JsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts' import { Inbox, agentMessage, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' @@ -41,17 +42,6 @@ const bindContext = Symbol('dsh.agent-loop.bind-context') /** Module-private publication marker. */ const publishAgent = Symbol('dsh.agent-loop.publish-agent') -/** Fully resolved input accepted only by the concrete driver's private delivery mechanism. */ -type ResolvedAgentInput = { - content: ContentBlock[] - source: MessageSource - meta: JsonValue | undefined -} & ( - | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } - | { target: 'next-step'; wakeup: true; contexts: HookContext[] } - | { target: 'next-step'; wakeup: false; contexts: [] } -) - /** Factory-owned controls that can operate only on the agent created with them. */ export interface PreparedReactLoopAgent { /** The unpublished concrete agent. */ @@ -241,8 +231,8 @@ export class ReactLoopAgent implements Agent { if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) } - /** Accept one fully resolved agent input through the concrete driver's private routing matrix. */ - #acceptInput(input: ResolvedAgentInput): AgentMessageId { + /** Accept one fully resolved agent input through the concrete driver's routing matrix. */ + acceptInput(input: ResolvedAgentInput): AgentMessageId { this.assertNotDisposed() const id = AgentMessageId(randomUUID()) const { target, wakeup } = input @@ -262,7 +252,7 @@ export class ReactLoopAgent implements Agent { } send(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.#acceptInput({ + return this.acceptInput({ content, target: 'next-turn', wakeup: true, @@ -273,7 +263,7 @@ export class ReactLoopAgent implements Agent { } queue(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.#acceptInput({ + return this.acceptInput({ content, target: 'next-turn', wakeup: false, @@ -284,7 +274,7 @@ export class ReactLoopAgent implements Agent { } steer(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.#acceptInput({ + return this.acceptInput({ content, target: 'next-step', wakeup: true, @@ -295,7 +285,7 @@ export class ReactLoopAgent implements Agent { } inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId { - return this.#acceptInput({ + return this.acceptInput({ content, target: 'next-step', wakeup: false, diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 608f1bad60..188bffe1a2 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -83,6 +83,40 @@ describe('Agent', () => { await ctx.fiber.dispose() }) + it('acceptInput exposes the fully resolved delivery path without applying helper defaults', async () => { + const adapter = new MockAdapter([textResponse('accepted')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const enqueued = Promise.withResolvers<{ id: string; source: unknown; wakeup: boolean }>() + ctx.on('agent/inbox/enqueue', (subject, message) => { + if (subject === agent) enqueued.resolve(message) + }) + + const id = agent.acceptInput({ + content: [{ type: 'text', text: 'advanced input' }], + source: { kind: 'plugin', plugin: 'advanced-caller' }, + contexts: [], + meta: { caller: 'advanced' }, + target: 'next-turn', + wakeup: true, + }) + await waitForIdle(ctx, agent) + + expect(await enqueued.promise).toMatchObject({ + id, + source: { kind: 'plugin', plugin: 'advanced-caller' }, + wakeup: true, + }) + expect(agent.session.events.find(event => event.type === 'user/message')) + .toMatchObject({ + data: { + source: { kind: 'plugin', plugin: 'advanced-caller' }, + meta: { caller: 'advanced' }, + }, + }) + await ctx.fiber.dispose() + }) + it('send() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 647cae2b22..b09334d873 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,12 +54,13 @@ Turn and step boundaries and the model token stream are durable `session/event` ### Agent interface (`types.ts`) -`Agent` is a structural interface. Public delivery methods name caller intent; the concrete driver keeps queue targeting and wakeup routing private ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `send()`, `queue()`, and `steer()` return an opaque `AgentMessageId` carried by that FIFO item's `agent/inbox/enqueue`/`dequeue`/`discard` events. Each snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. Omitting `options.source` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content. +`Agent` is a structural interface. `send()`, `queue()`, `steer()`, and `inject()` name common caller intents; `acceptInput(ResolvedAgentInput)` exposes the same acceptance path when a caller already has exact routing facts ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). Every `ResolvedAgentInput` field is mandatory, and its discriminated union excludes attached contexts from non-waking next-step injection. FIFO acceptance returns an opaque `AgentMessageId` carried by that item's `agent/inbox/enqueue`/`dequeue`/`discard` events. The driver snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. The helpers apply defaults: omitting `options.source` on `send()`, `queue()`, or `steer()` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content. - `agent.send(content, options?)` — queue one independent FIFO message as its own turn and wake the driver. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. - `agent.queue(content, options?)` — queue the same ordinary message without waking an idle driver. A lone queued item leaves `whenIdle()` resolved and rides along before the next waking message. - `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; while idle, create a waking ordinary turn. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `InjectOptions` deliberately has no attached contexts. `options.meta` persists opaque JSON state without rendering it. While a turn is open the injection joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. +- `agent.acceptInput(input)` — accept a fully specified route without helper defaults. `next-turn` targets the ordinary FIFO; `next-step` with wakeup targets steering and falls back to a waking ordinary turn while idle; `next-step` without wakeup is injection and requires `contexts: []`. Callers provide `meta: undefined` explicitly when they have no metadata. - `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `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.session`, `agent.status`, `agent.options`, `agent.id` @@ -78,7 +79,7 @@ Turn and step boundaries and the model token stream are durable `session/event` #### What the model sees -`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself. +The four helpers and `acceptInput` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself. #### Token effect diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index b120fb6689..3da7ec6e9b 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -68,16 +68,17 @@ export function AgentMessageId(id: string): AgentMessageId { /** * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` - * is the value `send`, `queue`, or `steer` returned to the caller, stable across - * this message's enqueue, dequeue, and discard events. Source defaults are - * already applied, so these are the exact values the item was accepted with. + * is the value returned by the accepting helper or {@link Agent.acceptInput}, + * stable across this message's enqueue, dequeue, and discard events. Source + * defaults, when applicable, are already applied, so these are the exact values + * the item was accepted with. * `steering` is true for an item drained between steps; otherwise it is claimed * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable * model-hidden state that lands on the eventual `user/message`/ * `steering/message`, not live-event routing data. */ export interface AgentMessage { - /** The id returned by the accepting `send`, `queue`, or `steer` call. */ + /** The id returned by the accepting helper or {@link Agent.acceptInput}. */ id: AgentMessageId content: ContentBlock[] source: MessageSource @@ -102,7 +103,7 @@ export interface CancelOptions { * An agent's lifecycle state, emitted on every transition as `agent/status`: * `idle` (parked, waiting for queued work), `running` (the driver is draining * work and may be closing or checkpointing a turn), `disposed` (terminal — no - * transition leaves it, and `send`/`queue`/`steer`/`inject` throw). + * transition leaves it, and every delivery method throws). */ export type AgentStatus = 'idle' | 'running' | 'disposed' @@ -120,6 +121,22 @@ export interface HookContext { meta?: JsonValue } +/** + * Fully specified input for {@link Agent.acceptInput}. Unlike the intent-named + * helpers, this form applies no defaults: callers provide content, source, + * contexts, metadata (including explicit `undefined`), target, and wakeup. + * The union excludes attached contexts from non-waking next-step injection. + */ +export type ResolvedAgentInput = { + content: ContentBlock[] + source: MessageSource + meta: JsonValue | undefined +} & ( + | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } + | { target: 'next-step'; wakeup: true; contexts: HookContext[] } + | { target: 'next-step'; wakeup: false; contexts: [] } +) + /** * Prompt interception result. `allow.content` replaces the prompt. Each * `additionalContexts` entry follows its declared placement: separate context @@ -223,6 +240,19 @@ export interface Agent { */ inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId + /** + * Accept one fully specified input through the same snapshot and routing path + * as the four intent-named helpers. `next-turn` targets the ordinary FIFO; + * `next-step`/wakeup targets steering (falling back to an ordinary waking turn + * while idle); and `next-step` without wakeup injects durable context without + * running the model. Every field is mandatory and no source or routing default + * is applied. Invalid input throws synchronously before notification, enqueue, + * or append. + * @param input - the resolved content, attribution, context, metadata, and routing facts. + * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. + */ + acceptInput(input: ResolvedAgentInput): AgentMessageId + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the @@ -275,8 +305,9 @@ declare module 'cordis' { * A detached, frozen item entered the agent's inbox (queued or steering * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record - * is the eventual `user/message`/`steering/message`. Injection - * through `agent.inject()` bypasses the FIFOs and does not emit this. + * is the eventual `user/message`/`steering/message`. Injection through + * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs + * and does not emit this. * @param agent - the agent whose inbox received the item. * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 39a65fa996..9bdb634a2c 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -15,6 +15,7 @@ import type { ContinuationStop, CreateAgentOptions, InjectOptions, + ResolvedAgentInput, ResumeAgentOptions, SendOptions, } from '@deepseek-ai/dsh-agent' @@ -31,6 +32,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, ...overrides, @@ -38,10 +40,20 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { } describe('AgentRegistry', () => { - it('keeps concrete delivery routing out of public options', () => { + it('keeps helper options semantic and makes advanced input fully specified', () => { + type OptionalInputKey = { + [Key in keyof ResolvedAgentInput]-?: Record extends Pick + ? Key + : never + }[keyof ResolvedAgentInput] + expectTypeOf<'target' extends keyof SendOptions ? true : false>().toEqualTypeOf() expectTypeOf<'wakeup' extends keyof SendOptions ? true : false>().toEqualTypeOf() expectTypeOf<'contexts' extends keyof InjectOptions ? true : false>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf['contexts']>() + .toEqualTypeOf<[]>() }) it('allows terminal stop policy to cooperate asynchronously with turn cancellation', () => { diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 2611d7f3e6..48c92293c8 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -52,6 +52,7 @@ function stubAgent(id: string): { agent: Agent; session: Session } { queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') }, + acceptInput: () => AgentMessageId('stub'), cancel() { status = 'idle' }, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 5f037d3c6b..94c90cf192 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -72,6 +72,7 @@ function stubAgentForSession(session: Session): StubAgent { else appendInjection(session, content, options) return AgentMessageId('stub') }, + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index a419cfd976..3aaa06c447 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -43,6 +43,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index c9aef7ce1f..f32dca3df6 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -244,7 +244,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -287,7 +287,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index d0aa8db72e..37e933d827 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 678403ef30..09799ccecd 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -31,6 +31,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index 354ebce0ba..a0ec66aff9 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index fce1d05bf7..c974ad90db 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index 51c06904d0..e06fe01aa5 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -27,6 +27,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 36fd40475c..6bf6fcb20d 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -166,6 +166,7 @@ export async function createTuiTestHarness AgentMessageId('stub'), + acceptInput: () => AgentMessageId('stub'), cancel(cause = { kind: 'user' }) { cancelled.push(cause) }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index a855d99f81..623f85a3d4 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2499,7 +2499,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) @@ -2523,7 +2523,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -2557,14 +2557,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -2594,7 +2594,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -2636,7 +2636,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 7aee2fc71b..897efa4cb0 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -76,6 +76,11 @@ "symbol": "InjectOptions", "source": "packages/core/agent/src/types.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ResolvedAgentInput", + "source": "packages/core/agent/src/types.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "AgentMessageId", From d3e24356ba5ade49bb44bc4b8dcbc929c8cb0a73 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 13:53:06 +0800 Subject: [PATCH 26/37] refactor(goal): loosen executor presence checks --- packages/goal/tool-goal/src/index.ts | 35 +++++++++---------- .../goal/tool-goal/tests/tool-goal.spec.ts | 8 ++--- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index a0df65a6ed..96e6389f85 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -132,14 +132,14 @@ function resolveConfig(config: Config): ResolvedConfig { return { blockedAfterConsecutiveRounds: blockedAfter } } -/** Remove only empty provider fillers from fields unused by the selected action. */ -function normalizeUpdateArgs(args: Record): Record { - const action = args['action'] - const empty = (value: unknown): boolean => value === undefined || value === null || value === '' || value === 0 - const unused = (key: string): boolean => key === 'blocked_reason' - ? action !== 'blocked' - : (key === 'objective' || key === 'max_goal_rounds') && action !== 'edit' - return Object.fromEntries(Object.entries(args).filter(([key, value]) => !unused(key) || !empty(value))) +/** Whether an optional string carries a meaningful action-specific value. */ +function hasText(value: string | undefined): boolean { + return value !== undefined && value !== '' +} + +/** Whether an optional round cap carries a meaningful action-specific value. */ +function hasRoundCap(value: number | undefined): boolean { + return value !== undefined && value !== 0 } /** Build the exact compare-and-set ref from model arguments. */ @@ -254,7 +254,7 @@ export function apply(ctx: Context, config: Config): void { presentCall: args => present('Create goal', 'other', args.objective), })) - const updateGoal = defineTool({ + ctx.tools.register(defineTool({ name: 'update_goal', description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + 'top-level human request. During an automatic continuation of the current goal, complete ' @@ -286,7 +286,9 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) - if (args.blocked_reason !== undefined) { + // Some strict-schema providers emit empty placeholders for every declared + // optional field. Reject only values that could change another action. + if (hasText(args.blocked_reason)) { throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') } const goal = ctx.goals.edit(execution.agent, ref, replacements) @@ -295,7 +297,7 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'pause' || args.action === 'resume') { requireDirectHuman(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) { + if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds) || hasText(args.blocked_reason)) { throw new HarnessError( 'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE', @@ -308,13 +310,13 @@ export function apply(ctx: Context, config: Config): void { return Promise.resolve(goalValue(goal)) } const authority = completionAuthority(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds)) { throw new HarnessError( 'objective and max_goal_rounds are valid only with action edit', 'GOAL_TOOL_INVALID_UPDATE', ) } - if (args.action === 'complete' && args.blocked_reason !== undefined) { + if (args.action === 'complete' && hasText(args.blocked_reason)) { throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') } if (args.action === 'blocked' @@ -343,10 +345,5 @@ export function apply(ctx: Context, config: Config): void { 'other', args.blocked_reason ?? args.objective ?? args.goal_id, ), - }) - // Object-literal execute methods do not use `this`; retaining the reference is safe. - // eslint-disable-next-line @typescript-eslint/unbound-method - const executeUpdate = updateGoal.execute - updateGoal.execute = (args, exec) => executeUpdate(normalizeUpdateArgs(args as Record), exec) - ctx.tools.register(updateGoal) + })) } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index a1261d3cb8..cf48b3dcbc 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -444,7 +444,7 @@ describe('goal tool state transitions', () => { action: 'pause', objective: '', max_goal_rounds: 0, - blocked_reason: null, + blocked_reason: '', }, root.agent) expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'edited' }) goal = ctx.goals.get(root.agent)! @@ -453,8 +453,8 @@ describe('goal tool state transitions', () => { goal_id: goal.id, revision: goal.revision, action: 'resume', - objective: null, - max_goal_rounds: '', + objective: '', + max_goal_rounds: 0, blocked_reason: '', }, root.agent) expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'edited' }) @@ -465,7 +465,7 @@ describe('goal tool state transitions', () => { revision: goal.revision, action: 'blocked', objective: '', - max_goal_rounds: null, + max_goal_rounds: 0, blocked_reason: 'actual blocker', }, root.agent) expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked' }) From 9848a9542be94fd5ae4692c17dbd903fd00f7191 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:02:53 +0800 Subject: [PATCH 27/37] docs: restore architecture headroom --- docs/architecture.i18n.yaml | 4 ++-- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 3eec5bed79..1125393ba3 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: 02b5d854f87e06db6b90d6402ea630e196bc5a23 -architecture.zh.md: 57adb6a2959ee9336364b403f736f914b72b42f4 +architecture.md: 937d0e5dc2d41140506ecb9483e1b2d35abcb41f +architecture.zh.md: 255035c4018b9a4edc788441225d47cb43757c76 diff --git a/docs/architecture.md b/docs/architecture.md index 02b5d854f8..937d0e5dc2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,7 +131,7 @@ Session events are turn-enclosed; reload closes an interrupted tail with a synth ### Agent Handles -`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins normally use intent-named `send()`, `queue()`, `steer()`, and `inject()`; callers with fully resolved routing may use `acceptInput()` with every field mandatory ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control cancellation and quiescence. Caller, provider, and handle co-own one awaited teardown. +`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use `send()`, `queue()`, `steer()`, and `inject()`; callers may use mandatory-field `acceptInput()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control lifecycle. Caller, provider, and handle co-own teardown. ### Agent Scope diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 57adb6a295..255035c401 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -131,7 +131,7 @@ forever: ### Agent 句柄 -`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件通常使用按意图命名的 `send()`、`queue()`、`steer()` 和 `inject()`;调用方若已持有完全解析的路由信息,可以使用 `acceptInput()`,其中每个字段均为必填项([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制取消和停稳过程。调用方、提供方和句柄共同拥有一项需等待完成的拆卸过程。 +`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`、`queue()`、`steer()` 和 `inject()`;调用方可以使用各字段均为必填项的 `acceptInput()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制生命周期。调用方、提供方和句柄共同拥有拆卸过程。 ### Agent 作用域 From aeb8b1f486f457bc44081667e82e5c9bb5b67606 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 14:09:00 +0800 Subject: [PATCH 28/37] feat: implement new session behavior to clear selection and show empty state - Added bilingual notes for the new session feature, detailing the transition to an empty state upon session creation. - Updated `SessionsService` to include a `clear()` method that resets the current selection and persists the empty state. - Enhanced the `EmptyState` component to reflect the new design, including workspace selection and input handling. - Modified CSS styles for improved layout and visual consistency in the empty state. - Updated tests to cover the new session clearing functionality and its effects on the UI. --- ...ew-session-clears-to-empty-state.i18n.yaml | 6 + ...07-24-new-session-clears-to-empty-state.md | 23 +++ ...24-new-session-clears-to-empty-state.zh.md | 23 +++ .../runtime/src/client/sessions/service.ts | 20 +- .../runtime/tests/sessions-service.spec.ts | 20 ++ .../src/client/skeleton/EmptyState.module.css | 105 ++++++++--- .../src/client/skeleton/EmptyState.tsx | 172 ++++++++++++------ .../src/client/skeleton/InputBar.module.css | 76 +++++++- .../src/client/skeleton/InputBar.tsx | 109 ++++++++--- .../ui-conversation/tests/input-bar.spec.tsx | 42 ++++- .../tests/skeleton-branches.spec.tsx | 15 +- .../ui-conversation/tests/skeleton.spec.tsx | 22 ++- packages/client/ui-sidebar/README.md | 2 +- .../ui-sidebar/src/client/contract/slots.ts | 4 +- .../client/ui-sidebar/src/client/index.ts | 12 +- .../client/ui-sidebar/tests/apply.spec.tsx | 16 +- 16 files changed, 532 insertions(+), 135 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml new file mode 100644 index 0000000000..5f41ac4b70 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-new-session-clears-to-empty-state.md: d730f3e0b658ea66b6026593f37a97893e32a4db +2026-07-24-new-session-clears-to-empty-state.zh.md: 602a2b774b569cfef0adcc253fe751f86b14f895 diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md new file mode 100644 index 0000000000..d730f3e0b6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md @@ -0,0 +1,23 @@ +# Agent Note: New Session clears onto the empty-state launch + +Status: implemented + +English | [中文](2026-07-24-new-session-clears-to-empty-state.zh.md) + +## Problem + +Sidebar "New Session" created and opened a blank session immediately, so the center column showed `ConversationRoot` with an empty transcript and the resident composer. The Figma NEW SESSION screen (`EmptyState` + shared `InputBar` hero) only rendered when `sessions.current` was already undefined, so the launch page was unreachable from the primary creation control. + +## Decision + +`SessionsService.clear()` wipes the persisted selection and `list.current`. Top-level sidebar creation entries (`onCreate()` with no cwd — New Session and New Workspace) call `clear()` so `AppFrame` renders `conversation.empty`. The empty state's first send still runs `conversation.startSession` (create → open → send) and reuses the same `InputBar` component as the resident composer (`variant="hero"`). Per-project "+" (`onCreate(cwd)`) keeps create-then-open until the empty-state picker can accept a seeded cwd. + +## Alternatives considered + +**Keep create-then-open for New Session and add a second empty chrome inside ConversationRoot when the transcript is empty.** Rejected: that duplicates the launch InputBar and breaks the empty→content ruling that one InputBar moves position rather than swapping components. + +**Route New Session through a dedicated route or slot outside selection.** Rejected for this pass: `conversation.empty` already owns the launch UI; clearing `current` is the existing empty branch. + +## Consequences + +New Session no longer mints a host session until the first send. Reloading after clear stays on the empty state. Project-scoped "+" still creates immediately. `EmptyState` stacks the Figma hero as fish + title, a Menu-backed workspace chip ("New Workspace" / basename / free-form path) above the card, then shared `InputBar` (`variant="hero"`), with a soft ellipse glow (figma 313:14109) centered behind the picker + card and width-locked to the card (`1051/776`) so it scales with it. `InputBar` paints the bottom chrome (attach / Plan / Read-only / model) with local native `` 状态——host 侧的 plan、access、model 接缝仍未接线。 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b9116971d0..324c574474 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -95,9 +95,10 @@ export class SessionsService { /** * Persisted selection cell (the durable half of `list.current`). Private on * purpose: reads go through the list snapshot; writes through {@link - * SessionsService.open}. Projection validates it against the live list - * instead of destructively pruning, so a selection survives transient list - * states (reconnect re-pull) and resurfaces when its session returns. + * SessionsService.open} / {@link SessionsService.clear}. Projection + * validates it against the live list instead of destructively pruning, so a + * selection survives transient list states (reconnect re-pull) and + * resurfaces when its session returns. */ private readonly selection: SnapshotStore<{ sessionId?: SessionId }> @@ -137,7 +138,7 @@ export class SessionsService { /** * Select a session as current. Unknown ids fail loud instead of navigating - * nowhere (the sole selection write path). + * nowhere. * @param id - session id (must exist in the list store). */ open(id: SessionId): void { @@ -148,6 +149,17 @@ export class SessionsService { this.list.update((draft) => { draft.current = id }) } + /** + * Clear the current selection so the layout shows the no-session empty + * state. Wipes the persisted selection too — a reload stays on empty until + * the user opens or starts a session. Staging holds the previous occupant + * across the blank (same masked-gap rule as a transient list miss). + */ + clear(): void { + this.selection.set({}) + this.list.update((draft) => { draft.current = undefined }) + } + /** * Create a session on the host. * @param opts - creation options (project directory). diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 97f223548c..33cfce43bb 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -133,6 +133,26 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone }) + it('clear() blanks list.current and the persisted selection', async () => { + const storage = new Map() + vi.stubGlobal('localStorage', { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { storage.set(k, v) }, + removeItem: (k: string) => { storage.delete(k) }, + clear: () => { storage.clear() }, + }) + const b = bench() + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + expect(storage.get('dsh.sessions.current')).toContain('s1') + b.svc.clear() + expect(b.svc.list.getSnapshot().current).toBeUndefined() + // Persisted wipe: a fresh service with the same storage stays on empty. + const again = bench() + await feedList(again, [{ id: 's1' }]) + expect(again.svc.list.getSnapshot().current).toBeUndefined() + }) + it('masks (not destroys) the selection while its session is off the list', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css index fbe2f25c09..53e618b122 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css @@ -1,6 +1,6 @@ -/* NEW SESSION hero: headline over the shared InputBar card, centered in the - conversation column. The card is the same component as the composer — - only positioning lives here. */ +/* NEW SESSION hero (figma 313:14149): fish + title, workspace chip above the + shared InputBar card. The input itself is InputBar — only stack geometry + lives here. */ .root { display: flex; @@ -11,16 +11,18 @@ padding: 24px; } -/* figma hero group 34:10409: headline block sits 36px above the input card. */ -.card { +/* Cap matches InputBar card width (776). Glow may paint past the sides. */ +.stack { display: flex; flex-direction: column; - gap: 36px; + align-items: stretch; + gap: 40px; width: 100%; max-width: 776px; + overflow: visible; } -/* figma 34:10411: fish + title row, gap 10, centered; title 26/32 wt600 (34:10414). */ +/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600. */ .headline { display: flex; align-items: center; @@ -32,37 +34,98 @@ color: var(--dsw-alias-label-primary); } -/* figma 34:10412/10413: brand-blue vector. */ +/* figma fish fill rides business blue. */ .fish { flex: none; color: var(--dsw-alias-state-business-primary); } -.picker { +/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is + centered on this block so it stays under the picker + InputBar together. */ +.body { + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + overflow: visible; +} + +/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */ +.glow { + position: absolute; + left: 50%; + top: 50%; + z-index: 0; + width: calc(100% * 1051 / 776); + aspect-ratio: 1051 / 468; + transform: translate(-50%, -50%); + pointer-events: none; +} + +.body > :not(.glow) { + position: relative; + z-index: 1; +} + +.workspaceRow { display: flex; align-items: center; min-width: 0; + /* Align with InputBar's left chrome (card pad 10 + attach). */ + padding-left: 10px; } -.select, -.customInput { - max-width: 320px; - padding: 4px 10px; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 12px; - background: var(--dsw-alias-bg-base); - font-size: 13px; +/* Folder + "New Workspace" + chevron (figma workspace trigger). */ +.workspace { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + height: 28px; + padding: 0 4px 0 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-primary); + font-size: 14px; line-height: 20px; - color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.workspace:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.folder { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.workspaceLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-caption); } .customInput { - width: 320px; + width: min(320px, 100%); + height: 28px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 8px; outline: none; + background: var(--dsw-alias-bg-base); + font-size: 14px; + line-height: 20px; + color: var(--dsw-alias-label-primary); } .customInput:focus { - /* Business blue, not brand-primary: that token resolves to ink in this sheet. */ border-color: var(--dsw-alias-state-business-primary); - color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx index 420ff7a622..edcf0cbad2 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -1,21 +1,27 @@ -// EmptyState (figma NEW SESSION screen): centered hero card built around the -// SAME InputBar component the resident composer uses (the empty→content -// transition is one component changing position, never a swap). Project -// picker: cwd set derived in-component from the standard useSessions hook -// (subscription is the framework's, derivation is a pure function — design -// §6) plus a free-form new-directory input; submit runs the startSession -// chain (create → open → send) in one service call. +// EmptyState (figma NEW SESSION screen): centered hero — fish + title, +// workspace picker row, then the SAME InputBar the resident composer uses +// (empty→content is a position move, never a swap). Project picker: cwd set +// derived in-component from useSessions plus a free-form new-directory path; +// submit runs startSession (create → open → send). -import { useMemo, useState } from 'react' -import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' +import { useId, useMemo, useState } from 'react' +import { + FishLogo, + IconChevronDownOutline14, + IconFolderOpen16, + Menu, + type MenuItem, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client' import type { EmptyStateSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' import css from './EmptyState.module.css' -/** Select sentinel for the free-form directory entry (impossible as a real path: not absolute). */ +/** Menu id for the free-form directory entry (not a filesystem path). */ const NEW_DIR = '::new-directory' +/** Menu id for the host default project directory (empty cwd on create). */ +const DEFAULT_DIR = '::default' /** Full props composed by reference from the contract (runtime share & injected share; no store). */ export type EmptyStateProps = EmptyStateSlotProps @@ -30,16 +36,26 @@ function deriveCwds(state: SessionListState): readonly string[] { return [...seen] } +/** Basename for the workspace chip; empty → the design's "New Workspace" label. */ +function workspaceLabel(cwd: string): string { + if (cwd === '') return 'New Workspace' + const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() + return base !== undefined && base !== '' ? base : cwd +} + export function EmptyState({ useSessions, startSession }: EmptyStateProps) { const list = useSessions(s => s) const cwds = useMemo(() => deriveCwds(list), [list]) // Local viewing state: the empty state owns no session, so its draft is // ephemeral by design (drafts are keyed by session id; there is none yet). const [draft, setDraft] = useState('') - const [cwd, setCwd] = useState('') + const [cwd, setCwd] = useState('') const [custom, setCustom] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) const [sending, setSending] = useState(false) const [error, setError] = useState(null) + // Stable filter id so multiple EmptyState mounts do not collide in the DOM. + const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` const submit = (mode: 'queue' | 'steer'): void => { const text = draft.trim() @@ -58,61 +74,105 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { // Success needs no cleanup: the session selection swaps this slot out for the session body. } - const picker = ( -
- {custom - ? ( - { setCwd(e.target.value) }} - /> - ) - : ( - { setCwd(e.target.value) }} + /> + ) + : ( + { setMenuOpen(false) }} + selectedId={selectedId} + items={items} + onSelect={(id) => { + if (id === NEW_DIR) { + setCustom(true) + setCwd('') + } else if (id === DEFAULT_DIR) { + setCustom(false) + setCwd('') + } else { + setCustom(false) + setCwd(id) + } + setMenuOpen(false) + }} + anchor={( + )} -
- ) + /> + ) return (
-
+
- {/* figma 34:10412: fish 34x25 leading the headline, gap 10. */} + {/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} Let's start building
- {}} - /> +
+ {/* figma 313:14109: soft ellipse behind workspace + InputBar; width + tracks the card (1051/776) so blur scales in userSpace with it. */} + +
{workspace}
+ {}} + /> +
) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 1139c3c9c1..04e4f661c5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -119,12 +119,81 @@ min-height: 84px; } -/* figma Frame 1123 (34:11463): pad 12/0/10/10, buttons vertically centered. */ +/* Toolbar: attach + Plan + Read-only on the left; model + send on the right + (figma Input_Bottom chrome). */ .row { display: flex; align-items: center; - justify-content: flex-end; - padding: 0 10px 10px 12px; + justify-content: space-between; + gap: 12px; + padding: 0 10px 10px 10px; + min-width: 0; +} + +.tools, +.trailing { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.trailing { + flex: none; + gap: 8px; +} + +/* Attach circle (figma + control): 28px, selector fill, primary glyph. */ +.add { + display: grid; + place-items: center; + flex: none; + width: 28px; + height: 28px; + border: none; + border-radius: 999px; + background: var(--dsw-specific-selector); + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.add:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-solid); +} + +.add:disabled { + opacity: 0.5; + cursor: default; +} + +/* Plan / Read-only / model — native state, no host wiring. -import { useEffect, useRef } from 'react' -import type { KeyboardEvent, MouseEvent, ReactNode } from 'react' +import { useEffect, useRef, useState } from 'react' +import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' +import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import css from './InputBar.module.css' /** Prompt failure surface (mirrors the session snapshot's promptError shape). */ @@ -24,13 +28,33 @@ export interface InputBarProps { /** Hero = empty-state centered card; composer = resident bottom bar. */ variant: 'hero' | 'composer' placeholder?: string - /** Optional leading accessory row content (the empty state mounts its cwd picker here). */ + /** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */ accessory?: ReactNode onDraftChange: (text: string) => void onSend: (mode: 'queue' | 'steer') => void onStop: () => void } +interface SelectOption { + id: string + label: string +} + +const PLAN_OPTIONS: readonly SelectOption[] = [ + { id: 'plan', label: 'Plan' }, + { id: 'agent', label: 'Agent' }, +] + +const READONLY_OPTIONS: readonly SelectOption[] = [ + { id: 'readonly', label: 'Read-only' }, + { id: 'readwrite', label: 'Read-write' }, +] + +const MODEL_OPTIONS: readonly SelectOption[] = [ + { id: 'v4-pro-high', label: 'DeepSeek-V4-Pro High' }, + { id: 'v4-pro', label: 'DeepSeek-V4-Pro' }, +] + export function InputBar({ draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop, }: InputBarProps) { @@ -48,6 +72,11 @@ export function InputBar({ }, 10) } + // Placeholder chrome: selection is local until plan/mode/model seams land. + const [planId, setPlanId] = useState('plan') + const [readonlyId, setReadonlyId] = useState('readonly') + const [modelId, setModelId] = useState('v4-pro-high') + // Locked while running: the browser drops keystrokes AND focus on a disabled // textarea — no sending mid-turn, stop or wait. const locked = disabled || running @@ -88,6 +117,25 @@ export function InputBar({ if (!empty && !disabled) onSend('queue') } + const renderSelect = ( + aria: string, + value: string, + options: readonly SelectOption[], + onPick: (id: string) => void, + ): ReactNode => ( + + ) + return (
{error !== null && ( @@ -116,25 +164,42 @@ export function InputBar({
{`${draft}\n`}
- +
+ + {renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} + {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} +
+
+ {renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)} + +
diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 660127946b..6bf58f7d59 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -19,7 +19,10 @@ function setup(over?: Partial) { } const view = render() const textarea = view.container.querySelector('textarea')! - const button = view.container.querySelector('button')! + // aria-label (not role name): title also contains 发送/停止 and would double-match. + const button = view.container.querySelector( + `button[aria-label="${over?.running === true ? '停止' : '发送'}"]`, + )! return { view, textarea, button, props } } @@ -97,7 +100,7 @@ describe('running lock and primary button', () => { const textarea = view.container.querySelector('textarea')! expect(document.activeElement).toBe(textarea) textarea.blur() - fireEvent.mouseDown(view.container.querySelector('button')!) + fireEvent.mouseDown(view.container.querySelector('button[aria-label="发送"]')!) expect(document.activeElement).toBe(textarea) }) @@ -129,3 +132,38 @@ describe('error strip and variants', () => { expect(view.container.querySelector('[class*="hero"]')).not.toBeNull() }) }) + +describe('placeholder chrome', () => { + it('renders attach / Plan / Read-only / model controls', () => { + const { view } = setup() + expect(view.getByLabelText('添加')).toBeTruthy() + expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan') + expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly') + expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high') + }) + + it('native select change updates the selected option', () => { + const { view } = setup() + const plan = view.getByLabelText('Plan mode') as HTMLSelectElement + fireEvent.change(plan, { target: { value: 'agent' } }) + expect(plan.value).toBe('agent') + const access = view.getByLabelText('Access mode') as HTMLSelectElement + fireEvent.change(access, { target: { value: 'readwrite' } }) + expect(access.value).toBe('readwrite') + }) + + it('model select can drop the High option', () => { + const { view } = setup() + const model = view.getByLabelText('Model') as HTMLSelectElement + fireEvent.change(model, { target: { value: 'v4-pro' } }) + expect(model.value).toBe('v4-pro') + expect(model.selectedOptions[0]?.textContent).toBe('DeepSeek-V4-Pro') + }) + + it('running locks the chrome selects and attach control', () => { + const { view } = setup({ running: true }) + expect((view.getByLabelText('添加') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true) + expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true) + }) +}) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 10dba860c0..b93b9c0b72 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -261,7 +261,7 @@ describe('EmptyState branches', () => { await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy()) }) - it('cwd derivation skips blank cwds; select picks, swaps to free-form, submits the typed path', async () => { + it('cwd derivation skips blank cwds; menu picks, swaps to free-form, submits the typed path', async () => { const startSession = vi.fn(() => Promise.resolve()) const view = render( { startSession={startSession} />, ) - const select = view.container.querySelector('select')! - expect([...(select as HTMLSelectElement).options].map(o => o.value)) - .toEqual(['', '/proj', '::new-directory']) - fireEvent.change(select, { target: { value: '/proj' } }) - expect((select as HTMLSelectElement).value).toBe('/proj') - fireEvent.change(select, { target: { value: '::new-directory' } }) + fireEvent.click(view.getByRole('button', { name: '项目目录' })) + expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) + .toEqual(['Default directory', '/proj', 'New directory…']) + fireEvent.click(view.getByRole('menuitem', { name: '/proj' })) + expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj') + fireEvent.click(view.getByRole('button', { name: '项目目录' })) + fireEvent.click(view.getByRole('menuitem', { name: 'New directory…' })) const custom = view.container.querySelector('input')! fireEvent.change(custom, { target: { value: '/typed/dir' } }) const textarea = view.container.querySelector('textarea')! diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 55cb46be2c..b2636bfd4b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -28,7 +28,8 @@ const sid = (s: string): SessionId => s as SessionId afterEach(cleanup) beforeEach(() => { - localStorage.clear() + // jsdom normally provides localStorage; some host Node builds surface it as undefined. + globalThis.localStorage?.clear() }) /** Minimal conversation snapshot slice the skeleton reads. */ @@ -95,11 +96,13 @@ describe('EmptyState', () => { const startSession = vi.fn(() => new Promise((_res, rej) => { reject = rej })) render() - const select = screen.getByRole('combobox', { name: '项目目录' }) - expect([...(select as HTMLSelectElement).options].map(o => o.value)) - .toEqual(['', '/w/app', '/w/lib', '::new-directory']) - fireEvent.change(select, { target: { value: '/w/app' } }) - const box = screen.getByPlaceholderText('Message to run task, plan and build') + const trigger = screen.getByRole('button', { name: '项目目录' }) + fireEvent.click(trigger) + const menu = screen.getByRole('menu') + expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) + .toEqual(['Default directory', '/w/app', '/w/lib', 'New directory…']) + fireEvent.click(screen.getByRole('menuitem', { name: '/w/app' })) + const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands') fireEvent.change(box, { target: { value: '造一个轮子' } }) fireEvent.keyDown(box, { key: 'Enter' }) expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' }) @@ -110,11 +113,12 @@ describe('EmptyState', () => { expect((box as HTMLTextAreaElement).value).toBe('造一个轮子') }) - it('new-directory option swaps the select for a free-form input', () => { + it('new-directory option swaps the chip for a free-form input', () => { const { useSessions } = fakeSessions([]) render( Promise.resolve()} />) - fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } }) - const custom = screen.getByPlaceholderText(/目录路径/) + fireEvent.click(screen.getByRole('button', { name: '项目目录' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'New directory…' })) + const custom = screen.getByPlaceholderText(/Directory path/) fireEvent.change(custom, { target: { value: '/tmp/fresh' } }) expect((custom as HTMLInputElement).value).toBe('/tmp/fresh') }) diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 7529bfe89c..c165455b1a 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Top-level New Session / New Workspace clear the selection onto `conversation.empty`; per-project "+" still create-then-opens. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). `src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx. diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 3012a760af..a5ce65ef59 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -25,8 +25,8 @@ export type SidebarRootInjected = { /** Open (switch to) a session. */ onOpen: (id: SessionId) => void /** - * Create a session and open it; cwd targets a project group (the - * sidebar's three creation entries all land in the new session). + * New-session affordance: no cwd clears selection onto the empty-state + * launch; a cwd create-then-opens a session in that project group. */ onCreate: (cwd?: string) => void /** Collapse the sidebar column (layout service action; owner share stays {collapsed,width}). */ diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index fe799a864f..be757ca183 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -27,9 +27,15 @@ export function apply(ctx: ClientContext): void { // list snapshot); layout keeps only panel geometry. onOpen: (id) => { ctx.sessions.open(id) }, onCreate: (cwd) => { - // Create-then-open: the sidebar's three creation entries all land - // in the new session (empty-state first-send stays with ui-conversation). - void ctx.sessions.create(cwd === undefined ? {} : { cwd }) + // Top-level New Session / New Workspace: clear selection so AppFrame + // shows conversation.empty (EmptyState + shared InputBar). Per-project + // "+" still create-then-opens into that cwd until workspace seeding + // reaches the empty-state picker. + if (cwd === undefined) { + ctx.sessions.clear() + return + } + void ctx.sessions.create({ cwd }) .then((id: SessionId) => { ctx.sessions.open(id) }) }, onToggleSidebar: () => { ctx.layout.toggleSidebar() }, diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index 6b6b4f9474..44fdae18f8 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -26,7 +26,12 @@ async function bench() { byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } }, current: undefined, }) - const sessions = { list, create: vi.fn(async () => sid('minted')), open: vi.fn() } + const sessions = { + list, + create: vi.fn(async () => sid('minted')), + open: vi.fn(), + clear: vi.fn(), + } const layout = { toggleSidebar: vi.fn() } ctx.provide('sessions', sessions) ctx.provide('layout', layout) @@ -91,14 +96,15 @@ describe('apply', () => { expect(sessions.open).toHaveBeenCalledWith('a') injected.onCreate() - expect(sessions.create).toHaveBeenCalledWith({}) + expect(sessions.clear).toHaveBeenCalledOnce() + expect(sessions.create).not.toHaveBeenCalled() + + injected.onCreate('/proj') + expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' }) // create-then-open lands after the create promise resolves. await Promise.resolve() await Promise.resolve() expect(sessions.open).toHaveBeenCalledWith('minted') - - injected.onCreate('/proj') - expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' }) }) it('teardown unregisters the slot entry', async () => { From 06143b1a86fcee75d3f2887717f908400e2e6375 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 14:36:44 +0800 Subject: [PATCH 29/37] fix: cr --- .../2026-07-23-trajectory-step-cell.i18n.yaml | 4 +- .../2026-07-23-trajectory-step-cell.md | 4 +- .../2026-07-23-trajectory-step-cell.zh.md | 4 +- .../client/ui-trajectory/src/client/layout.ts | 66 +++++++++++++++---- .../ui-trajectory/tests/layout.spec.tsx | 63 ++++++++++++++++++ 5 files changed, 124 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml index fb39ae1301..1702c90c43 100644 --- a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-trajectory-step-cell.md: edf31dcc72baa980caf0aa90fb8d5ec53d44346d -2026-07-23-trajectory-step-cell.zh.md: dbe813d48c3f3ac1c0926e45137624d758109c55 +2026-07-23-trajectory-step-cell.md: 414c3aac856fb5e60f0e4cf42f8e7b410cdf3413 +2026-07-23-trajectory-step-cell.zh.md: aa76b422f165ebf6918b3781fdfe38797a34ba51 diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md index 42d896b81a..414c3aac85 100644 --- a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md @@ -14,9 +14,9 @@ The trajectory tab needs a reusable step row and turn-list chrome that can show - [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 38px step row with kinds User / Message / Tool (no Think, Call, or Result rows). Reasoning blocks are skipped (no block-level clock). Each `tool-call` + paired `tool-result` folds into one Tool row (`name ·` truncated args) whose Time is `result.time − callTime` when both are known. Message rows carry Input/Output/Think token columns from `assistant.usage`. Own-duration Time uses `+Ns` / `+N.1s`, or `—` when absent. Selected state draws a 2px inset `--dsw-alias-brand-primary-new-colorprimary-new-color` ring (`selected` prop) and is not wired to chat selection. - [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — sticky Turn bar paints full-bleed `ghost-active-fill`; title/columns and the Message/Step body sit in a centered `max-width: 880px` lane. Cell trailing columns share the Turn header geometry (`320 = 4×71 + 3×12`); cells use pad 20/8. -- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only, and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`). +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only (including the empty fallback when there is no text block), and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`). `user/message` has no wire turn, so each User row is enclosed in the next assistant/steering turn, else the in-flight `partial` turn, else `lastAssistantTurn + 1` (or `1`). Context nodes emit no cell but still advance the Message duration cursor. -[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time; Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration). +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time (including skipped context); Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md index c6bcde7deb..aa76b422f1 100644 --- a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md @@ -14,9 +14,9 @@ trajectory 标签页需要可复用的步骤行与轮次列表 chrome,以展 - [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 高 38px 的步骤行,类型为 User / Message / Tool(无 Think、Call、Result 行)。reasoning 块跳过(无块级时钟)。每对 `tool-call` + `tool-result` 折成一行 Tool(`name ·` 加截断参数),Time 在两端皆知时为 `result.time − callTime`。Message 行携带来自 `assistant.usage` 的 Input/Output/Think token 列。自身耗时 Time 使用 `+Ns` / `+N.1s`,缺失时为 `—`。选中态绘制 2px 内嵌的 `--dsw-alias-brand-primary-new-colorprimary-new-color` 环(`selected` prop),且未接线到 chat 选中。 - [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — 粘性 Turn 条背景通栏铺 `ghost-active-fill`;标题/列标与 Message/Step 主体落在居中的 `max-width: 880px` 内容道。单元格右侧列与 Turn 标头共用几何(`320 = 4×71 + 3×12`);cell pad 20/8。 -- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上,并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。 +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上(含无 text 块时的空回退行),并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。`user/message` 无线上 turn,故每条 User 行归入下一 assistant/steering 的 turn,否则归入进行中的 `partial` turn,否则为 `lastAssistantTurn + 1`(或 `1`)。context 节点不产出单元格,但仍推进 Message 耗时游标。 -[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间;Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。 +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间(含跳过的 context);Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。 ## Alternatives considered diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 03bfcb6893..e188498554 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -54,6 +54,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const turns = new Map }>() let index = 0 let prevAbsTime: number | null = null + let lastAssistantTurn: number | null = null const bucket = (turn: number) => { let entry = turns.get(turn) @@ -74,9 +75,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T steps.set(step, list) } - for (const node of nodes) { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + /* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */ + if (node === undefined) continue if (node.kind === 'user' || node.kind === 'steering') { - const turn = node.kind === 'steering' ? node.turn : 0 + // user/message has no turn on the wire; enclose it in the next assistant + // (or partial) turn, else open the turn after the last assistant. + const turn = node.kind === 'steering' + ? node.turn + : enclosingUserTurn(nodes, i, partial, lastAssistantTurn) pushMessage(turn, { absTime: finiteTime(node.time), cell: { @@ -96,6 +104,12 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const last = laidList[laidList.length - 1] if (last !== undefined) index = last.cell.index prevAbsTime = finiteTime(node.time) ?? prevAbsTime + lastAssistantTurn = node.turn + continue + } + if (node.kind === 'context') { + // No trajectory cell, but the surface still advances the duration cursor. + prevAbsTime = finiteTime(node.time) ?? prevAbsTime continue } if (node.kind === 'tool-result') { @@ -149,6 +163,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T }) } + // Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1. const prologue = turns.get(0) if (prologue !== undefined) { turns.delete(0) @@ -269,11 +284,9 @@ function expandAssistant( index: ++index, kind: 'message', text: summarizeText(block.text), timeSeconds: messageDuration, } - if (!usageAttached && usage !== undefined) { - if (usage.inputTokens !== undefined) cell.input = usage.inputTokens - if (usage.outputTokens !== undefined) cell.output = usage.outputTokens - if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens - usageAttached = true + if (!usageAttached) { + attachUsage(cell, usage) + usageAttached = usage !== undefined } out.push({ absTime: nodeAbs, cell }) continue @@ -302,14 +315,45 @@ function expandAssistant( } if (out.length === 0 && !streaming) { - out.push({ - absTime: nodeAbs, - cell: { index: ++index, kind: 'message', text: '', timeSeconds: messageDuration }, - }) + // Reasoning-only / empty success still owns provider usage on the Message row. + const cell: TrajectoryCellProps = { + index: ++index, kind: 'message', text: '', timeSeconds: messageDuration, + } + attachUsage(cell, usage) + out.push({ absTime: nodeAbs, cell }) } return out } +/** + * Turn that encloses a user/message: next assistant/steering turn, else the + * in-flight partial, else the turn after the last finalized assistant (or 1). + */ +function enclosingUserTurn( + nodes: ConversationSnapshot['nodes'], + userIndex: number, + partial: ConversationSnapshot['partial'], + lastAssistantTurn: number | null, +): number { + for (let i = userIndex + 1; i < nodes.length; i++) { + const n = nodes[i] + /* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */ + if (n === undefined) continue + if (n.kind === 'assistant' || n.kind === 'steering') return n.turn + } + if (partial !== null) return partial.turn + if (lastAssistantTurn !== null) return lastAssistantTurn + 1 + return 1 +} + +/** Copy provider usage onto a Message cell when present. */ +function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void { + if (usage === undefined) return + if (usage.inputTokens !== undefined) cell.input = usage.inputTokens + if (usage.outputTokens !== undefined) cell.output = usage.outputTokens + if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens +} + function indexResults(nodes: ConversationSnapshot['nodes']): Map { const map = new Map() for (const node of nodes) { diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index 4dc395221d..9773f6fe57 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -140,4 +140,67 @@ describe('deriveTrajectoryLayout', () => { const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2') }) + + it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => { + const nodes = [ + { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null }, + { + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 0, + blocks: [{ kind: 'text', text: 'ok1' }], + }, + { kind: 'user', seq: 3, time: 3_000, content: [{ type: 'text', text: 'second' }], source: null }, + { + kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 0, + blocks: [{ kind: 'text', text: 'ok2' }], + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns.map((t) => t.turn)).toEqual([1, 2]) + expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1']) + expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2']) + }) + + it('keeps usage on the fallback Message row when assistant has no text block', () => { + const nodes = [ + { + kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0, + blocks: [{ kind: 'reasoning', text: '…' }], + usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 }, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message') + expect(message).toMatchObject({ + text: '', input: 11, output: 22, think: 3, + }) + }) + + it('advances the duration cursor over context nodes', () => { + const nodes = [ + { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null }, + { + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, + blocks: [{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{}' }], + }, + { + kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', + call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100, + content: [], isError: false, callView: null, resultView: null, + }, + { + kind: 'context', seq: 4, time: 9_000, + content: [{ type: 'text', text: 'extra' }], source: null, + }, + { + kind: 'assistant', seq: 5, time: 10_000, turn: 1, step: 0, + blocks: [{ kind: 'text', text: 'done' }], + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const message = turns[0]?.groups + .flatMap((g) => g.cells) + .find((c) => c.kind === 'message' && c.text === 'done') + // From context at 9s, not from the earlier user/tool surfaces. + expect(message?.timeSeconds).toBe(1) + }) }) From b02b438667d29b4cdc3ec83e4ea95404945e9d7e Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 15:08:36 +0800 Subject: [PATCH 30/37] refactor(agent): align delivery method names --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 6 +- ...ied-send-and-coalesced-user-messages.zh.md | 6 +- ...7-24-intent-named-agent-delivery.i18n.yaml | 4 +- .../2026-07-24-intent-named-agent-delivery.md | 12 ++-- ...26-07-24-intent-named-agent-delivery.zh.md | 12 ++-- docs/agent-lifecycle.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 14 ++-- docs/cookbook/extension-cookbook.zh.md | 14 ++-- docs/cordis-catalog/events.md | 4 +- docs/core-data-structures/core.md | 12 ++-- docs/core-data-structures/persistence.md | 2 +- .../core-data-structures/session-reference.md | 2 +- docs/defensive-patterns.md | 2 +- docs/i18n/style-samples.md | 4 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../cordis-agent/tests/cordis-tools.e2e.ts | 10 +-- .../headless-agent/tests/code-mode.e2e.ts | 4 +- .../headless-agent/tests/coding-task.e2e.ts | 2 +- .../headless-agent/tests/compaction.e2e.ts | 2 +- .../headless-agent/tests/full-loop.e2e.ts | 2 +- examples/headless-agent/tests/resume.e2e.ts | 4 +- .../headless-agent/tests/todo-write.e2e.ts | 2 +- .../bash/tool-bash/tests/integration.spec.ts | 10 +-- .../tests/compact-loop-repro.spec.ts | 10 +-- packages/context/session-reference/README.md | 2 +- .../time-context/tests/time-context.spec.ts | 8 +-- .../tests/workspace-context.e2e.ts | 8 +-- .../tests/workspace-context.spec.ts | 8 +-- .../cordis/tool-cordis/src/api-catalog.ts | 20 +++++- .../cordis/tool-cordis/tests/inspect.spec.ts | 2 + .../tool-cordis/tests/integration.spec.ts | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 16 ++--- packages/core/agent-loop/src/inbox.ts | 2 +- .../agent-loop/tests/agent-initiator.spec.ts | 2 +- packages/core/agent-loop/tests/agent.spec.ts | 12 ++-- packages/core/agent-loop/tests/cancel.spec.ts | 4 +- .../tests/config-session-id.spec.ts | 10 +-- .../tests/contract-regressions.spec.ts | 8 +-- .../agent-loop/tests/coverage-edges.spec.ts | 6 +- .../agent-loop/tests/inbox-invariant.spec.ts | 8 +-- .../agent-loop/tests/interception.spec.ts | 6 +- packages/core/agent-loop/tests/loop.spec.ts | 8 +-- .../core/agent-loop/tests/properties.spec.ts | 6 +- .../agent-loop/tests/request-cache.e2e.ts | 4 +- .../tests/request-reconstruction.spec.ts | 2 +- .../agent-loop/tests/request-recovery.spec.ts | 2 +- packages/core/agent-loop/tests/resume.spec.ts | 12 ++-- .../agent-loop/tests/scope-lifecycle.spec.ts | 6 +- .../core/agent-loop/tests/tool-calls.spec.ts | 32 ++++----- .../core/agent-loop/tests/tool-order.spec.ts | 4 +- .../core/agent-loop/tests/turn-stop.spec.ts | 4 +- packages/core/agent/README.md | 8 +-- packages/core/agent/src/types.ts | 14 ++-- packages/core/agent/tests/agent.spec.ts | 6 +- .../agent-spine-demo/tests/agent-core.spec.ts | 8 +-- packages/examples/cli-demo/src/cli.ts | 2 +- packages/examples/cli-demo/tests/cli.spec.ts | 2 +- packages/fs/tool-fs/tests/fs-tools.e2e.ts | 4 +- .../command-goal/tests/command-goal.spec.ts | 4 +- packages/goal/goal-session/src/index.ts | 2 +- packages/goal/goal-session/src/prompt.ts | 2 +- .../goal-session/tests/goal-session.spec.ts | 30 ++++---- packages/goal/goal/tests/goal.spec.ts | 4 +- packages/goal/tool-goal/README.md | 2 +- packages/goal/tool-goal/src/authority.ts | 2 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 4 +- .../tests/repeat-tool-guard.spec.ts | 36 +++++----- .../hooks/hooks-claude/tests/bridge.spec.ts | 20 +++--- .../hooks-claude/tests/coverage-cases.ts | 60 ++++++++-------- .../hooks/hooks-codex/tests/bridge.spec.ts | 12 ++-- .../hooks/hooks-codex/tests/coverage-cases.ts | 68 +++++++++---------- packages/host/runtime/src/api-proxy.ts | 2 +- .../host/runtime/tests/host-runtime.spec.ts | 10 +-- .../tests/loader-composition.spec.ts | 2 +- packages/llm/llm-retry/tests/retry.spec.ts | 26 +++---- .../plan/plan-mode/tests/integration.spec.ts | 8 +-- packages/pty/pty-local/tests/index.spec.ts | 6 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 4 +- .../tool-pty/tests/loader-composition.spec.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- .../tests/fixtures/crash-child.ts | 2 +- .../tests/multi-subagent.spec.ts | 4 +- .../subagent-fork/tests/subagent-fork.spec.ts | 14 ++-- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 2 +- .../tests/structured.spec.ts | 8 +-- .../tests/subagent-inprocess.spec.ts | 2 +- .../subagent-spawn/tests/spawn.e2e.ts | 2 +- .../tests/subagent-spawn.spec.ts | 4 +- packages/tasks/tasks/tests/tasks.spec.ts | 4 +- .../todo/tool-todo/tests/integration.spec.ts | 4 +- packages/ui/acp/README.md | 2 +- packages/ui/acp/acp-feature-support.md | 2 +- packages/ui/acp/src/index.ts | 6 +- packages/ui/acp/tests/dispose.spec.ts | 4 +- packages/ui/acp/tests/edges.spec.ts | 2 +- packages/ui/jsonrpc/src/server.ts | 2 +- packages/ui/jsonrpc/tests/server.spec.ts | 2 +- packages/ui/tui/README.md | 8 +-- packages/ui/tui/src/index.ts | 2 +- packages/ui/tui/tests/harness.ts | 4 +- packages/ui/tui/tests/tui.spec.ts | 12 ++-- .../tool-ralph/tests/integration.spec.ts | 2 +- scripts/gen-cordis-api.ts | 47 +++++++++++-- scripts/gen-doc-graphs.ts | 2 +- 113 files changed, 462 insertions(+), 405 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index d175853372..b0afe427ce 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: 7ee51ed18cbf6a12136abe67f412251c4c1f0eb3 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d260f1ba396cf9b6a90773b218a8df3d158d95c9 +2026-07-22-unified-send-and-coalesced-user-messages.md: bf0ae468c4783b73e2dbd0e1bc50b9bd2f50cb3f +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 17913d2636e3ee5e5ae69f9c554935ba861d14d9 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 7ee51ed18c..bf0ae468c4 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -12,7 +12,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj ## Decision -**One acceptance mechanism, four intent helpers.** The concrete loop resolves `send`, `queue`, `steer`, and `inject` into one (`target` × `wakeup`) acceptance mechanism. `send` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface also exposes that mechanism as `acceptInput(ResolvedAgentInput)` for callers that already have fully resolved routing; every field is mandatory, and the discriminated input type excludes attached contexts from injection. The [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering. +**One acceptance mechanism, four intent helpers.** The concrete loop resolves `followup`, `queue`, `steer`, and `inject` into one (`target` × `wakeup`) acceptance mechanism. `followup` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface exposes that mechanism as `send(ResolvedAgentInput)` for callers that already have fully resolved routing; every field is mandatory, and the discriminated input type excludes attached contexts from injection. The [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering. **inject keeps its mechanism.** `inject` appends durable model-facing context at the current log position (deferred behind an executing tool batch), or opens a one-shot `injection` turn when idle. It bypasses the FIFOs entirely, accepts no attached contexts, and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. @@ -34,9 +34,9 @@ Separately, `context/message` and `user/message` had converged: the surface proj ## Consequences -The concrete driver has one delivery mechanism. Four common helpers hide its (`target` × `wakeup`) matrix behind caller intent, while `acceptInput` exposes the fully resolved matrix for advanced callers. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every “human prompt?” check simplify to a `source` test. The goal fold's channel split moves from event type to `source.round`, and every consumer that filtered `context/message` filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged: an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. +The concrete driver has one delivery mechanism. Four common helpers hide its (`target` × `wakeup`) matrix behind caller intent, while `send` exposes the fully resolved matrix for advanced callers. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every “human prompt?” check simplify to a `source` test. The goal fold's channel split moves from event type to `source.round`, and every consumer that filtered `context/message` filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged: an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. -Internally, `wakeup` is the “should the model run” signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `queue()` item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is left hanging). `SendOptions.meta` on a queued or steering message is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally absent from the live `AgentMessage`, which carries only routing facts. Every enqueued id gets exactly one terminal lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` both at the in-turn stop point and on the post-turn drain of late steering, and disposal discards any still-pending items before the loop exits. The `agent/inbox/*` payload is frozen so a listener cannot mutate the shared correlation object mid-dispatch, and a loop-authored continuation reason is snapshotted and frozen like public steering. Injection validates its payload before opening an idle one-shot turn; `InjectOptions` omits attached contexts, while the non-waking next-step variant of `ResolvedAgentInput` requires an empty context tuple. +Internally, `wakeup` is the “should the model run” signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `queue()` item stays parked at idle and rides along the next waking follow-up, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is left hanging). `SendOptions.meta` on a queued or steering message is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally absent from the live `AgentMessage`, which carries only routing facts. Every enqueued id gets exactly one terminal lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` both at the in-turn stop point and on the post-turn drain of late steering, and disposal discards any still-pending items before the loop exits. The `agent/inbox/*` payload is frozen so a listener cannot mutate the shared correlation object mid-dispatch, and a loop-authored continuation reason is snapshotted and frozen like public steering. Injection validates its payload before opening an idle one-shot turn; `InjectOptions` omits attached contexts, while the non-waking next-step variant of `ResolvedAgentInput` requires an empty context tuple. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index d260f1ba39..17913d2636 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -12,7 +12,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` ## 决策 -**一种接受机制,四种意图辅助方法。** 具体循环把 `send`、`queue`、`steer` 和 `inject` 解析到同一个(`target` × `wakeup`)接受机制中。`send` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口还将该机制暴露为 `acceptInput(ResolvedAgentInput)`;调用方若已持有完全解析的路由信息,即可使用该方法。使用时必须提供所有字段,可辨识输入类型也不允许注入携带附加上下文。取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。 +**一种接受机制,四种意图辅助方法。** 具体循环把 `followup`、`queue`、`steer` 和 `inject` 解析到同一个(`target` × `wakeup`)接受机制中。`followup` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口将该机制暴露为 `send(ResolvedAgentInput)`;调用方若已持有完全解析的路由信息,即可使用该方法。使用时必须提供所有字段,可辨识输入类型也不允许注入携带附加上下文。取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。 **inject 保留其机制。** `inject` 在当前日志位置追加持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时开启一个一次性的 `injection` 轮次。它完全绕过 FIFO,不接受附加上下文,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 @@ -34,9 +34,9 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` ## 后果 -具体驱动器只有一个投递机制。四种常用辅助方法以调用方意图封装其(`target` × `wakeup`)矩阵,而 `acceptInput` 则向高级调用方暴露完全解析后的矩阵。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处「是否人类提示词?」检查都简化为一次 `source` 判断。goal 折叠的通道区分从事件类型改到 `source.round`,此前过滤 `context/message` 的每个消费方都改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变:空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 +具体驱动器只有一个投递机制。四种常用辅助方法以调用方意图封装其(`target` × `wakeup`)矩阵,而 `send` 则向高级调用方暴露完全解析后的矩阵。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处「是否人类提示词?」检查都简化为一次 `source` 判断。goal 折叠的通道区分从事件类型改到 `source.round`,此前过滤 `context/message` 的每个消费方都改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变:空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 -在内部,`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `queue()` 项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会让任何等待者悬而未决)。排队消息或 steering 消息上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 上,后者只携带路由事实。每个已入队的 id 都恰好得到一个终止性生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`,既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时;dispose(资源释放)会在 loop 退出前丢弃所有仍在等待的项。`agent/inbox/*` 的事件载荷已被冻结,因此监听器无法在分发中途修改共享的关联对象,而由 loop 生成的继续原因会像公开 steering 一样被快照并冻结。注入会在打开空闲状态的一次性轮次之前校验其载荷;`InjectOptions` 不包含附加上下文,而 `ResolvedAgentInput` 中不唤醒的下一步变体要求使用空上下文元组。 +在内部,`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `queue()` 项会停泊在空闲状态,并随下一条会唤醒驱动器的后续消息一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会让任何等待者悬而未决)。排队消息或 steering 消息上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 上,后者只携带路由事实。每个已入队的 id 都恰好得到一个终止性生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`,既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时;dispose(资源释放)会在 loop 退出前丢弃所有仍在等待的项。`agent/inbox/*` 的事件载荷已被冻结,因此监听器无法在分发中途修改共享的关联对象,而由 loop 生成的继续原因会像公开 steering 一样被快照并冻结。注入会在打开空闲状态的一次性轮次之前校验其载荷;`InjectOptions` 不包含附加上下文,而 `ResolvedAgentInput` 中不唤醒的下一步变体要求使用空上下文元组。 ## 相关 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml index 69ec4e0895..ec8c9d105f 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-intent-named-agent-delivery.md: 54ea6353a516830795d51c395706241a4570260d -2026-07-24-intent-named-agent-delivery.zh.md: 0d903a0d5dd383a154ade02ee8bed607d1f986b1 +2026-07-24-intent-named-agent-delivery.md: 32b0502350063610efff746cbef779e8225055eb +2026-07-24-intent-named-agent-delivery.zh.md: ce8860b397497f4de587a9373d1cd300cf7dab29 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md index 54ea6353a5..32b0502350 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md @@ -14,14 +14,14 @@ Sharing helper implementations through an abstract `Agent` class also makes the `Agent` is a structural interface with four intent-named delivery helpers: -- `send()` queues an ordinary turn and wakes the driver. +- `followup()` queues an ordinary turn and wakes the driver. - `queue()` queues an ordinary turn without waking an idle driver. - `steer()` targets the running turn and requests another step; while idle it becomes a waking ordinary turn. - `inject()` appends model-facing context without running the model. -`send`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` is absent: ordinary `send` already names the established common operation, and “follow-up” is false for a session's first message. +`followup`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` names the waking next-turn operation used for both initial prompts and later independent prompts. -`Agent` also exposes `acceptInput(ResolvedAgentInput)` for callers that already hold the complete route. Every field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. The discriminated union requires the empty context tuple for non-waking next-step injection. `ReactLoopAgent` implements this method once, and all four helpers resolve their defaults before delegating to it. The name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. +`Agent` also exposes `send(ResolvedAgentInput)` for callers that already hold the complete route. Every field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. The discriminated union requires the empty context tuple for non-waking next-step injection. `ReactLoopAgent` implements this method once, and all four helpers resolve their defaults before delegating to it. The method accepts the delivery facts as one resolved input; acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. The target/wakeup matrix is an explicit advanced part of the structural `Agent` interface, not the ordinary helper options and not a base-class implementation seam. With one concrete adapter, a protected subclass seam would be hypothetical; callers and tests use the same public interface. @@ -29,11 +29,11 @@ The target/wakeup matrix is an explicit advanced part of the structural `Agent` **Keep the resolved primitive private.** This minimizes the public method count, but forces adapters that already hold exact target/wakeup facts to reverse-map them into helper calls and removes the reusable type for that resolved state. -**Use configurable `send` as the primitive.** Even mandatory routing arguments would make the common method carry advanced concerns. Keeping `send` semantic preserves its simple defaulted call shape; the separate discriminated input type rejects attached contexts on injection. +**Use configurable `send(content, options)` as the primitive.** Optional routing fields would let advanced-looking calls silently become ordinary sends. One mandatory discriminated input keeps the resolved route explicit and rejects attached contexts on injection. -**Rename the primitive to `sendInternal` or `addMessageAdvanced`.** A public method must not describe itself as internal. `addMessageAdvanced` is also inaccurate because acceptance may wake, queue, steer, inject, or later discard work; `acceptInput` names the synchronous guarantee instead. +**Name the primitive `acceptInput`, `sendInternal`, or `addMessageAdvanced`.** `acceptInput` describes the synchronous acceptance boundary but not the caller's delivery action. A public method must not describe itself as internal, and `addMessageAdvanced` is inaccurate because the input may later be discarded. -**Keep `followup` as the waking-turn helper.** Existing production callers use `send`, while `followup` has no TypeScript caller and does not describe the first ordinary message. Reusing `send` preserves the familiar intent without retaining an alias. +**Use `send(content, options)` as the waking-turn helper.** This reserves the shortest delivery name for one preset and forces callers with complete target/wakeup facts through a less direct primitive name. `followup` distinguishes the next-turn/wakeup intent while leaving `send` for the resolved operation. **Bind source first through a public sender object.** A source-bound adapter can make attribution explicit for repeated producers, but it adds another public object and does not simplify one-off human input. The existing source default remains, with the standing requirement that non-human producers label their content. diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md index 0d903a0d5d..ce8860b397 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md @@ -14,14 +14,14 @@ Status: implemented `Agent` 是一个结构化接口,提供四种按意图命名的投递辅助方法: -- `send()` 将一个普通轮次入队并唤醒驱动器。 +- `followup()` 将一个普通轮次入队并唤醒驱动器。 - `queue()` 将一个普通轮次入队,但不唤醒空闲驱动器。 - `steer()` 以运行中的轮次为目标并请求另一个步骤;空闲时,它会变成一个唤醒式普通轮次。 - `inject()` 追加面向模型的上下文,但不运行模型。 -`send`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。接口不提供 `followup`:普通 `send` 已经为既有的常见操作命名,而「follow-up」不适用于会话的第一条消息。 +`followup`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。`followup` 为唤醒式下一轮操作命名,这项操作既用于初始提示词,也用于后续的独立提示词。 -`Agent` 还公开 `acceptInput(ResolvedAgentInput)`,供已经持有完整路由的调用方使用。每个字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。对于目标为下一步且不触发唤醒的注入,可辨识联合类型要求上下文为空元组。`ReactLoopAgent` 统一实现这个方法;四个辅助方法都会先解析各自的默认值,再委托给它。这个名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 +`Agent` 还公开 `send(ResolvedAgentInput)`,供已经持有完整路由的调用方使用。每个字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。对于目标为下一步且不触发唤醒的注入,可辨识联合类型要求上下文为空元组。`ReactLoopAgent` 统一实现这个方法;四个辅助方法都会先解析各自的默认值,再委托给它。调用方以一个解析后的输入向该方法提交各项投递事实;接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 结构化 `Agent` 接口显式包含面向高级用法的 target/wakeup 矩阵;该矩阵不属于普通辅助方法的选项,也不是基类实现 seam。只有一个具体适配器时,protected 子类 seam 只是假想的;调用方和测试使用同一个公开接口。 @@ -29,11 +29,11 @@ Status: implemented **让解析后的原语保持私有。** 这会把公开方法数量降到最低,但会迫使已经持有精确 target/wakeup 路由信息的适配器将其反向映射为辅助方法调用,也会移除表示该解析后状态的可复用类型。 -**使用可配置的 `send` 作为原语。** 即使强制提供所有路由参数,也会让这个常用方法承载高级用法的复杂性。让 `send` 只表达语义意图,可以保留其带默认值的简单调用形式;单独的可辨识输入类型则会拒绝为注入附加上下文。 +**使用可配置的 `send(content, options)` 作为原语。** 可选路由字段会让看似高级的调用悄然变成普通投递。一个各字段均为必填项的可辨识输入既能让解析后的路由保持显式,也会拒绝为注入附加上下文。 -**把原语重命名为 `sendInternal` 或 `addMessageAdvanced`。** 公开方法不应在名称中把自己称为内部方法。`addMessageAdvanced` 也不准确,因为接受操作可能唤醒、排队、中途引导、注入,或在之后丢弃工作;`acceptInput` 描述的则是同步边界所保证的事实。 +**把原语命名为 `acceptInput`、`sendInternal` 或 `addMessageAdvanced`。** `acceptInput` 描述了同步接受边界,却没有描述调用方的投递操作。公开方法不应在名称中把自己称为内部方法,`addMessageAdvanced` 也不准确,因为输入可能在之后被丢弃。 -**保留 `followup` 作为唤醒轮次的辅助方法。** 现有生产调用方使用 `send`,而 `followup` 没有 TypeScript 调用方,也无法描述第一条普通消息。复用 `send` 可以保留熟悉的意图,同时不保留别名。 +**使用 `send(content, options)` 作为唤醒轮次的辅助方法。** 这会让最简短的投递名称只表示一种预设操作,并迫使持有完整 target/wakeup 信息的调用方改用一个不够直接的原语名称。`followup` 明确区分下一轮/唤醒意图,并把 `send` 留给解析后的操作。 **先通过公开的发送方对象绑定来源。** 对于重复产生消息的来源,来源绑定适配器可以明确标注归属,但它会增加一个公开对象,也不会简化一次性的人类输入。现有的来源默认值予以保留,同时继续要求非人类生产方标注其内容。 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 9a97873a17..5f1e43eb17 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -17,7 +17,7 @@ sequenceDiagram participant Session participant Persistence participant SDK as UI or SDK listener - User->>Agent: send(content) + User->>Agent: followup(content) Agent-->>SDK: agent/inbox/enqueue Agent->>Driver: queued work wakes driver Driver-->>SDK: agent/status running diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 1125393ba3..214e2c9fba 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: 937d0e5dc2d41140506ecb9483e1b2d35abcb41f -architecture.zh.md: 255035c4018b9a4edc788441225d47cb43757c76 +architecture.md: 76c58e03282ef6d736da7d65b05c534c05c4c318 +architecture.zh.md: dddccf1e9238e617a453395731ee3f620ba5749d diff --git a/docs/architecture.md b/docs/architecture.md index 937d0e5dc2..76c58e0328 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,7 +131,7 @@ Session events are turn-enclosed; reload closes an interrupted tail with a synth ### Agent Handles -`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use `send()`, `queue()`, `steer()`, and `inject()`; callers may use mandatory-field `acceptInput()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control lifecycle. Caller, provider, and handle co-own teardown. +`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use intent helpers `followup()`, `queue()`, `steer()`, and `inject()`; callers with exact routing facts use mandatory-field `send()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control lifecycle. Caller, provider, and handle co-own teardown. ### Agent Scope diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 255035c401..dddccf1e92 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -131,7 +131,7 @@ forever: ### Agent 句柄 -`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`、`queue()`、`steer()` 和 `inject()`;调用方可以使用各字段均为必填项的 `acceptInput()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制生命周期。调用方、提供方和句柄共同拥有拆卸过程。 +`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用按意图命名的辅助方法 `followup()`、`queue()`、`steer()` 和 `inject()`;持有确切路由信息的调用方使用各字段均为必填项的 `send()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制生命周期。调用方、提供方和句柄共同拥有拆卸过程。 ### Agent 作用域 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 5d5dc81f2a..03d868a264 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 056be4298ed2bec2b78ed777d58f1f8a60a34b78 -extension-cookbook.zh.md: 41cdd4a7d14f32494d1dd5ae4a63c098d5640bdc +extension-cookbook.md: c13b46e06a3b34512cd371e6a4868a6e932a575f +extension-cookbook.zh.md: aeb5f905278c07344c68d80da05dc5daf299b4f6 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 056be4298e..c13b46e06a 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -36,7 +36,7 @@ This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an ## A UI plugin -A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.send()` / `agent.steer()`. +A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`. ```ts import type { Context } from 'cordis' @@ -54,13 +54,13 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }])) } ``` ## A client-driver plugin (external protocol bridge) -A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `send()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. +A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `followup()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the permission-prompt answerer it registers on the approval seam. @@ -99,9 +99,9 @@ Every product feature maps to a listener on a documented extension seam — the |---|---| | Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | | `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control | -| `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | +| `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue | | Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` | -| Queued + steering messages | core `Agent.send()` / `Agent.steer()` | +| Queued + steering messages | core `Agent.followup()` / `Agent.steer()` | | Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/post-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing | | AGENTS.md (root) | a section provider reading the file | @@ -118,8 +118,8 @@ Every product feature maps to a listener on a documented extension seam — the | MCP | one plugin per server: discover tools → `ctx.tools.register()` | | Skills | section + tool registration; `inject()` skill content on invocation | | Memory | section provider + tool | -| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `send(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy | -| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `send()` | +| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy | +| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `followup()` | | Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` | | Model adapters | `LlmAdapter` subclass via `registerAdapter` (`dsh-llm-deepseek`, `dsh-llm-pi-ai`) | | Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 41cdd4a7d1..aeb5f90527 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -36,7 +36,7 @@ export function apply(ctx: Context) { ## UI 插件 -UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.send()` / `agent.steer()` 将输入驱动回去。 +UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。 ```ts import type { Context } from 'cordis' @@ -54,13 +54,13 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }])) } ``` ## 客户端驱动插件(外部协议桥接) -*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `send()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。 +*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `followup()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。 `packages/ui/acp` 是完整的工作示例:它将 agent 桥接到 ACP(Agent Client Protocol)(基于 stdio 的 JSON-RPC),使 Zed 及其他 ACP 编辑器能够驱动它。其 README 描述了完整的方法接口以及它在审批 seam 上注册的权限提示应答器。 @@ -99,9 +99,9 @@ export function apply(ctx: Context) { |---|---| | 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | | `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 | -| `/loop` | 在 `turn/end` 会话事件上 `send()` 下一次迭代;或强制继续 | +| `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 | | 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 | -| 排队消息 + steering(中途引导) | 核心 `Agent.send()` / `Agent.steer()` | +| 排队消息 + steering(中途引导) | 核心 `Agent.followup()` / `Agent.steer()` | | 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/post-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | | 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 | | AGENTS.md(根目录) | 一个读取该文件的 section provider | @@ -118,8 +118,8 @@ export function apply(ctx: Context) { | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | | Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | 记忆 | section provider + 工具 | -| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `send(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 | -| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `send()` | +| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 | +| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `followup()` | | 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` | | 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) | | 插件热重载 | 每个注册都是一个 `ctx.effect` → vendor 的 HMR(热模块替换)直接生效 | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d2a57d21b2..ed9bebdc92 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -146,7 +146,7 @@ Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/t ### `agent/inbox/enqueue` — emit -A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs and does not emit this. +A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` or equivalent `send()` routing bypasses the FIFOs and does not emit this. ```ts cordis-catalog /** @@ -154,7 +154,7 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record * is the eventual `user/message`/`steering/message`. Injection through - * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs + * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs * and does not emit this. * @param agent - the agent whose inbox received the item. * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 82c7ebc24d..b409a37284 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -361,7 +361,7 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types ```ts type-equiv /** - * Options for {@link Agent.send}, {@link Agent.queue}, and {@link Agent.steer}. + * Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}. * An omitted source attests direct human input as `{ kind: 'user' }` and may * authorize policy consumers, so non-human producers must label their content. */ @@ -392,7 +392,7 @@ The advanced acceptance form makes every default explicit and rules out attached ```ts type-equiv /** - * Fully specified input for {@link Agent.acceptInput}. Unlike the intent-named + * Fully specified input for {@link Agent.send}. Unlike the intent-named * helpers, this form applies no defaults: callers provide content, source, * contexts, metadata (including explicit `undefined`), target, and wakeup. * The union excludes attached contexts from non-waking next-step injection. @@ -423,7 +423,7 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t ```ts type-equiv /** * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` - * is the value returned by the accepting helper or {@link Agent.acceptInput}, + * is the value returned by the accepting helper or {@link Agent.send}, * stable across this message's enqueue, dequeue, and discard events. Source * defaults, when applicable, are already applied, so these are the exact values * the item was accepted with. @@ -433,7 +433,7 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t * `steering/message`, not live-event routing data. */ interface AgentMessage { - /** The id returned by the accepting helper or {@link Agent.acceptInput}. */ + /** The id returned by the accepting helper or {@link Agent.send}. */ id: AgentMessageId content: ContentBlock[] source: MessageSource @@ -489,7 +489,7 @@ interface Agent { * @param options - source, attached contexts, and durable model-hidden meta. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - send(content: ContentBlock[], options?: SendOptions): AgentMessageId + followup(content: ContentBlock[], options?: SendOptions): AgentMessageId /** * Queue an ordinary message without waking an idle driver. The item retains @@ -539,7 +539,7 @@ interface Agent { * @param input - the resolved content, attribution, context, metadata, and routing facts. * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. */ - acceptInput(input: ResolvedAgentInput): AgentMessageId + send(input: ResolvedAgentInput): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 9bfdca56bd..ee01363c50 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -6,7 +6,7 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite ## The flush checkpoint -`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) until `session/flush`. The loop awaits an ordinary turn's checkpoint before claiming the next queue item; synchronous idle `inject()` schedules its checkpoint without blocking `send()`, and disposal still drains it. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported through `agent/error` and the logger — never as a session event past the closed turn — while the backend keeps its buffered events for the next flush. +`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) until `session/flush`. The loop awaits an ordinary turn's checkpoint before claiming the next queue item; synchronous idle `inject()` schedules its checkpoint without blocking `followup()`, and disposal still drains it. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported through `agent/error` and the logger — never as a session event past the closed turn — while the backend keeps its buffered events for the next flush. ## Crash recovery preserves an interrupted turn diff --git a/docs/core-data-structures/session-reference.md b/docs/core-data-structures/session-reference.md index dbe3c43d35..d9a73c08c6 100644 --- a/docs/core-data-structures/session-reference.md +++ b/docs/core-data-structures/session-reference.md @@ -36,7 +36,7 @@ interface SessionReferenceCandidate { ## Prepared messages -Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `send()` or `steer()` call. +Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `followup()` or `steer()` call. ```ts type-equiv /** Message payload and the zero-or-one durable snapshot contexts bound to it. */ diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index fe74a9d19f..a6fe43ef69 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -12,7 +12,7 @@ When an interface documents two valid ways to signal something — an adapter ma ## Async state is not synchronous state -`agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. +`agent.followup()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-follow-up result: several queued follow-ups run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. ## Dispose must reach quiescence, not just request it diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index dd970b7c12..b20b2a4c90 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -28,9 +28,9 @@ **dispose(资源释放)必须等待所有任务完全停稳,不能仅下发终止指令就返回**:如果清理过程只发出终止或中断信号,却不等任务停止就返回,就会留下孤儿进程。清理应采用异步方式,等待所有子任务彻底退出(先发出终止信号,再等待退出);发出信号前应先关闭监听器与通知注册表,使延迟到达的完成事件不再触发通知。测试要证明 dispose 的确等到清理完成:执行完 `await fiber.dispose()` 后进程 PID 立即消失,不能只检查进程最终会自行消亡。 -> **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. +> **Async state is not synchronous state** — `agent.followup()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-follow-up result: several queued follow-ups run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. -**异步状态不等同于同步瞬时状态**:调用 `agent.send()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `send()` 的结果:多次排队的 `send()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。 +**异步状态不等同于同步瞬时状态**:调用 `agent.followup()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `followup()` 的结果:多次排队的 `followup()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。 ## ③ 测试政策清单 diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index b95ab420af..487f0517b1 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n acceptInput(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index b82fa568f1..56cc640977 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n acceptInput(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index ae3651fdaf..b5f8e27564 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -42,7 +42,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Use cordis_mount to mount a plugin that listens to the \'agent/status\' ' + 'cordis event and logs every change with console.log. Reply "mounted" once done.', @@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif }) expect(resultText(mid)).toContain('dyn-') - agent.send([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }]) + agent.followup([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }]) await waitForIdle(ctx, agent) const after = await ctx.tools.execute({ @@ -72,7 +72,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Give yourself a new tool: use cordis_mount to mount a plugin with ' + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) ' @@ -119,7 +119,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Mount TWO separate plugins with cordis_mount. First a provider: apply calls ' + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with ' @@ -144,7 +144,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif .flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text)) expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true) - agent.send([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }]) + agent.followup([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }]) await waitForIdle(ctx, agent) // The consumer must have been parked by cordis itself: service gone, diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index a2bc25cbcc..86c1559b83 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -311,7 +311,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p ctx = await codeModeHarness(workdir) const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, ' + 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), ' @@ -363,7 +363,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, }) - handle.agent.send([{ + handle.agent.followup([{ type: 'text', text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?', }]) diff --git a/examples/headless-agent/tests/coding-task.e2e.ts b/examples/headless-agent/tests/coding-task.e2e.ts index a5f525e5c3..4a827858bb 100644 --- a/examples/headless-agent/tests/coding-task.e2e.ts +++ b/examples/headless-agent/tests/coding-task.e2e.ts @@ -56,7 +56,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'In the current directory, `node add.test.js` fails because add.js has a bug. ' + 'Fix add.js so the test passes, run `node add.test.js` to verify, and report the result. ' diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index fcebddb863..a96b7f3611 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -46,7 +46,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }) const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a ' + 'time using cat (a separate bash command for each). After reading all four, tell me how ' diff --git a/examples/headless-agent/tests/full-loop.e2e.ts b/examples/headless-agent/tests/full-loop.e2e.ts index db2eec63fc..4f61ec3fa3 100644 --- a/examples/headless-agent/tests/full-loop.e2e.ts +++ b/examples/headless-agent/tests/full-loop.e2e.ts @@ -30,7 +30,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) + agent.followup([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) await waitForIdle(ctx, agent) const events = [...agent.session.events] diff --git a/examples/headless-agent/tests/resume.e2e.ts b/examples/headless-agent/tests/resume.e2e.ts index 01c7d52393..a4ded767da 100644 --- a/examples/headless-agent/tests/resume.e2e.ts +++ b/examples/headless-agent/tests/resume.e2e.ts @@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses sessionId: SESSION_ID, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, })).agent - first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) + first.followup([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) await waitForIdle(ctx, first) await ctx.fiber.dispose() ctx = undefined @@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // The prior user turn is in the rehydrated log before the model is asked. expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET) - resumed.send([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }]) + resumed.followup([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }]) await waitForIdle(ctx, resumed) // The model recalls it — only possible from the resumed history. diff --git a/examples/headless-agent/tests/todo-write.e2e.ts b/examples/headless-agent/tests/todo-write.e2e.ts index daf5c018b1..c3053572d9 100644 --- a/examples/headless-agent/tests/todo-write.e2e.ts +++ b/examples/headless-agent/tests/todo-write.e2e.ts @@ -28,7 +28,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ type: 'text', text: + agent.followup([{ type: 'text', text: 'Use the todo_write tool to record a plan of exactly two steps: first ' + '"inspect the failing test" (in_progress), then "apply the fix" (pending). ' + 'Send both in one todo_write call, then reply with the single word DONE.' }]) diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 7d6d9de97d..8adf2165e0 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -109,7 +109,7 @@ describe('bash tool through the agent loop', () => { const location = ctx.sessionPersistence.locate(agent.session.header) expect(location?.kind).toBe('jsonl') - agent.send([{ type: 'text', text: 'inspect the current session' }]) + agent.followup([{ type: 'text', text: 'inspect the current session' }]) await waitForIdle(ctx, agent) const result = findEvent(events(agent), 'tool/result') @@ -128,7 +128,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'run echo integration-ok' }]) + agent.followup([{ type: 'text', text: 'run echo integration-ok' }]) await waitForIdle(ctx, agent) const log = events(agent) @@ -160,7 +160,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'run exit 9' }]) + agent.followup([{ type: 'text', text: 'run exit 9' }]) await waitForIdle(ctx, agent) const toolResult = findEvent(events(agent), 'tool/result') @@ -180,7 +180,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }]) + agent.followup([{ type: 'text', text: 'run echo bg-ok in the background' }]) await waitForIdle(ctx, agent) const firstResult = findEvent(events(agent), 'tool/result') @@ -200,7 +200,7 @@ describe('bash tool through the agent loop', () => { expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' }) // The next turn collects the output through the generic task tool. - agent.send([{ type: 'text', text: 'collect it' }]) + agent.followup([{ type: 'text', text: 'collect it' }]) await waitForIdle(ctx, agent) const readResult = findEvent(events(agent), 'tool/result', 'last') expect(readResult.data.isError).toBe(false) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index f9f61e4096..83c18f78b4 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -197,7 +197,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () provider: 'unconfigured-agent-fallback', model: 'unconfigured-agent-fallback', }) - agent.send([{ type: 'text', text: 'do a routed multi-step task' }]) + agent.followup([{ type: 'text', text: 'do a routed multi-step task' }]) await waitForIdle(ctx, agent) expect(agent.session.requestHeader()?.config.model).toBe('mock') @@ -215,7 +215,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () const { ctx } = await harness(8) try { const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'do tool work' }]) + agent.followup([{ type: 'text', text: 'do tool work' }]) await waitForIdle(ctx, agent) const events = [...agent.session.events] @@ -241,7 +241,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () const { ctx } = await harness(8) try { const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'do a long multi-step task' }]) + agent.followup([{ type: 'text', text: 'do a long multi-step task' }]) await waitForIdle(ctx, agent) const events = [...agent.session.events] @@ -297,7 +297,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () }) seedOverflowHistory(agent) - agent.send([{ type: 'text', text: 'continue from history' }]) + agent.followup([{ type: 'text', text: 'continue from history' }]) await agent.whenIdle() expect(adapter.conversationRequests).toHaveLength(2) @@ -360,7 +360,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () try { const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' }) seedOverflowHistory(agent) - agent.send([{ type: 'text', text: 'continue from history' }]) + agent.followup([{ type: 'text', text: 'continue from history' }]) await agent.whenIdle() expect(adapter.conversationRequests).toHaveLength(3) diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index b3a4b2013d..4fca9a9977 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -5,7 +5,7 @@ ## Public API - `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id or cwd, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses its latest log-backed title as the mention label and falls back to the session id; titles and message bodies are not searched. -- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `send()` or `steer()`. +- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`. - `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text. ## Snapshot semantics diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 2f9d679f21..424363d4b1 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -42,7 +42,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { session, status: 'running', ctx: new Context(), - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { @@ -52,7 +52,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } @@ -374,7 +374,7 @@ describe('real agent-loop request history', () => { }) const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'start' }]) + agent.followup([{ type: 'text', text: 'start' }]) await agent.whenIdle() expect(laterSawReading).toBe(true) @@ -400,7 +400,7 @@ describe('real agent-loop request history', () => { })) const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'start' }]) + agent.followup([{ type: 'text', text: 'start' }]) await agent.whenIdle() expect(adapter.requests).toHaveLength(2) diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index f80ee5acde..fbeacb5496 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -78,7 +78,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode it('obeys a probe instruction loaded from the workspace', async () => { const live = await harness() - live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }]) + live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }]) await waitForIdle(live.ctx, live.agent) expect(finalText([...live.agent.session.events])).toContain(PROBE) @@ -90,7 +90,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode await writeFile(join(workdir!, 'pkg/AGENTS.md'), `If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${NESTED_PROBE}.\n`) await writeFile(join(workdir!, 'pkg/deep/file.txt'), 'This file exists only to trigger nested workspace instructions.\n') - live.agent.send([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }]) + live.agent.followup([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }]) await waitForIdle(live.ctx, live.agent) expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE) @@ -99,11 +99,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => { const live = await harness() await writeFile(join(workdir!, 'trigger.txt'), 'This file triggers workspace instruction reconciliation.\n') - live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }]) + live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }]) await waitForIdle(live.ctx, live.agent) await writeFile(join(workdir!, 'AGENTS.md'), `The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${UPDATED_PROBE}.\n`) - live.agent.send([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }]) + live.agent.followup([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }]) await waitForIdle(live.ctx, live.agent) const events = [...live.agent.session.events] diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 7ce1c4da28..c8c33f3a4b 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -177,7 +177,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { options: {}, session, status: 'idle', - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { @@ -188,7 +188,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } @@ -1717,11 +1717,11 @@ describe('dynamic nested workspace context injection', () => { }, })) - agent.send([{ type: 'text', text: 'read and abort' }]) + agent.followup([{ type: 'text', text: 'read and abort' }]) await agent.whenIdle() expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) - agent.send([{ type: 'text', text: 'retry the read' }]) + agent.followup([{ type: 'text', text: 'retry the read' }]) await agent.whenIdle() const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 801ec729b6..bb8392825f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -899,7 +899,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/enqueue', mode: 'emit', signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, message: AgentMessage): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection through\n * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs\n * and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection through\n * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs\n * and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', }, { @@ -1181,7 +1181,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n acceptInput(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n}', }, { name: 'AgentCancelCause', @@ -1547,6 +1547,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', }, + { + name: 'LlmAdapter', + declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise;\n resolveModelContext(_provider: string, _model: string): Promise;\n abstract stream(options: GenerateOptions): AsyncIterable;\n}', + }, { name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', @@ -1755,6 +1759,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SendOptions', declaration: 'export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}', }, + { + name: 'Session', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + }, { name: 'SessionAvailability', declaration: 'export type SessionAvailability = \'live\' | \'persisted\';', @@ -1887,6 +1895,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionSearchRequest', declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', }, + { + name: 'SessionSurface', + declaration: 'export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n}', + }, { name: 'SessionSurfaceSnapshot', declaration: 'export interface SessionSurfaceSnapshot {\n session: SessionHeader;\n capturedThroughSeq: number | null;\n events: SurfaceEvent[];\n}', @@ -2019,6 +2031,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SurfaceEventType', declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';', }, + { + name: 'SurfaceIntent', + declaration: 'export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n}', + }, { name: 'SurfaceOp', declaration: 'export type SurfaceOp = \'append\' | {\n op: \'replace\';\n start: number;\n end: number;\n};', diff --git a/packages/cordis/tool-cordis/tests/inspect.spec.ts b/packages/cordis/tool-cordis/tests/inspect.spec.ts index 3085cfd057..18b4d04df7 100644 --- a/packages/cordis/tool-cordis/tests/inspect.spec.ts +++ b/packages/cordis/tool-cordis/tests/inspect.spec.ts @@ -61,6 +61,8 @@ describe('cordis_inspect', () => { // generated TYPE_API — a consumer can see field types, not just names). expect(report).toContain('type shapes (referenced by the signatures above') expect(report).toContain('export interface ToolExecution') + expect(report).toContain('export class Session') + expect(report).toContain('export interface SessionSurface') // A type only reachable through a NOT-live service (e.g. bash) is scoped out. expect(report).not.toContain('export interface BashRunResult') // The inherited ctx surface closes the section. diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index d68f6be349..7f917e5b3c 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -47,7 +47,7 @@ describe('cordis tools through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }]) + agent.followup([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }]) await waitForIdle(ctx, agent) const log = agent.session.events diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index cdbd512d0d..47250c354f 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `ReactLoopAgent` adapter, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -`ReactLoopAgent.acceptInput()` implements the public fully resolved acceptance path. The `send()`/`queue()`/`steer()`/`inject()` helpers resolve every optional field before delegating to it; direct callers provide mandatory content, source, contexts, metadata, target, and wakeup facts through `ResolvedAgentInput`. `send()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` or equivalent `acceptInput()` routing enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` and non-waking next-step acceptance require an empty context tuple, bypass both FIFOs, and append durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. +`ReactLoopAgent.send()` implements the public fully resolved acceptance path. The `followup()`/`queue()`/`steer()`/`inject()` helpers resolve every optional field before delegating to it; direct callers provide mandatory content, source, contexts, metadata, target, and wakeup facts through `ResolvedAgentInput`. `followup()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` or equivalent `send()` routing enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` and non-waking next-step acceptance require an empty context tuple, bypass both FIFOs, and append durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 22b292b107..e09acfff40 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -232,7 +232,7 @@ export class ReactLoopAgent implements Agent { } /** Accept one fully resolved agent input through the concrete driver's routing matrix. */ - acceptInput(input: ResolvedAgentInput): AgentMessageId { + send(input: ResolvedAgentInput): AgentMessageId { this.assertNotDisposed() const id = AgentMessageId(randomUUID()) const { target, wakeup } = input @@ -251,8 +251,8 @@ export class ReactLoopAgent implements Agent { return id } - send(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.acceptInput({ + followup(content: ContentBlock[], options?: SendOptions): AgentMessageId { + return this.send({ content, target: 'next-turn', wakeup: true, @@ -263,7 +263,7 @@ export class ReactLoopAgent implements Agent { } queue(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.acceptInput({ + return this.send({ content, target: 'next-turn', wakeup: false, @@ -274,7 +274,7 @@ export class ReactLoopAgent implements Agent { } steer(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.acceptInput({ + return this.send({ content, target: 'next-step', wakeup: true, @@ -285,7 +285,7 @@ export class ReactLoopAgent implements Agent { } inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId { - return this.acceptInput({ + return this.send({ content, target: 'next-step', wakeup: false, @@ -488,9 +488,9 @@ export class ReactLoopAgent implements Agent { if (this._status !== 'disposed') { // Snapshot any still-pending inbox items, then CLEAR and mark disposed // BEFORE emitting the discard — mirroring cancel()'s snapshot→clear→emit - // order so a re-entrant send()/cancel() from a discard listener throws + // order so a re-entrant followup()/cancel() from a discard listener throws // `disposed` (or finds an empty inbox) instead of leaking or double- - // discarding an id. `send()` emits enqueue unconditionally, so the discard + // discarding an id. `followup()` emits enqueue unconditionally, so the discard // is unconditional too (even on an unpublished rollback) to keep every // enqueued id matched. const discarded = this.#inbox.pending() diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index f2e8addda2..0f93884515 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -58,7 +58,7 @@ export class Inbox { * True while a queued message wants to wake the driver — the "should the loop * run" signal read by the idle wait's fast path, the loop's idle-publish * check, and `whenIdle`. A `wakeup:false` (quiet) item alone leaves this - * false, so the driver stays parked until a waking send (or a waking item + * false, so the driver stays parked until a waking follow-up (or a waking item * ahead of it in FIFO order) drives the loop; the quiet item then rides along. */ get hasWakingQueued(): boolean { diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index c6c767d422..a2359562a8 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string): void { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } /** Adapter that holds both drivers at the same awaited continuation. */ diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 3a9fbd9a17..08b77711f9 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -48,7 +48,7 @@ function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): P } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } describe('Agent', () => { @@ -83,7 +83,7 @@ describe('Agent', () => { await ctx.fiber.dispose() }) - it('acceptInput exposes the fully resolved delivery path without applying helper defaults', async () => { + it('send exposes the fully resolved delivery path without applying helper defaults', async () => { const adapter = new MockAdapter([textResponse('accepted')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -92,7 +92,7 @@ describe('Agent', () => { if (subject === agent) enqueued.resolve(message) }) - const id = agent.acceptInput({ + const id = agent.send({ content: [{ type: 'text', text: 'advanced input' }], source: { kind: 'plugin', plugin: 'advanced-caller' }, contexts: [], @@ -117,7 +117,7 @@ describe('Agent', () => { await ctx.fiber.dispose() }) - it('send() throws after disposal', async () => { + it('followup() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) let agent!: Agent @@ -129,7 +129,7 @@ describe('Agent', () => { await fiber.dispose() await driverDone(agent) - expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') + expect(() => { agent.followup([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) it('disposal discards still-pending inbox items so every id gets a terminal event', async () => { @@ -490,7 +490,7 @@ describe('Agent', () => { const { agent } = prepared prepared.markPublished() const dispose = prepared.startDriver() - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 62b75df154..32418fe112 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter) { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } /** Resolve on the agent's next idle transition (event-based, not status poll). */ @@ -63,7 +63,7 @@ describe('Agent.cancel()', () => { ctx.on('agent/cancel-requested', (subject, cause) => { if (subject !== agent) return seen.push(`first:${cause.kind}`) - subject.send([{ type: 'text', text: 'queued by cancel observer' }]) + subject.followup([{ type: 'text', text: 'queued by cancel observer' }]) throw new Error('observer failed') }) ctx.on('agent/cancel-requested', (subject, cause) => { diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 2103dd6831..d144127498 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -98,7 +98,7 @@ describe('config-driven session id', () => { first = ctx.agents.get(SessionId('config-exact-reload')) } expect(first).toBeDefined() - first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) + first!.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx, first!) await firstLoop.dispose() @@ -110,7 +110,7 @@ describe('config-driven session id', () => { } expect(second).toBeDefined() expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') - second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } }) + second!.followup([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } }) await waitForIdle(ctx, second!) await ctx.sessions.flush(second!.session) const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload')) @@ -335,7 +335,7 @@ describe('config-driven session id', () => { expect(a1.id).toBe(a1.session.id) expect(a1.session.id).toMatch(idPattern) expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined() - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -354,7 +354,7 @@ describe('config-driven session id', () => { expect(a2.id).toBe(a2.session.id) expect(a2.session.id).toMatch(idPattern) expect(a2.session.id).not.toBe(a1.session.id) - a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) + a2.followup([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) await waitForIdle(ctx2, a2) await ctx2.fiber.dispose() }) @@ -375,7 +375,7 @@ describe('config-driven session id', () => { await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent - a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 24eccf5cbd..2b85691151 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } describe('session log records what agent/step-result actually produced', () => { @@ -818,7 +818,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { source: { kind: 'plugin', plugin: 'context-source' }, meta: { version: 1 }, }] - agent.send(content, { source, contexts }) + agent.followup(content, { source, contexts }) content[0]!.text = 'caller-mutated-send' source.plugin = 'caller-mutated-source' contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-context' } @@ -874,7 +874,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { notifiedContexts = info.contexts }) - agent.send([{ type: 'text', text: 'start' }]) + agent.followup([{ type: 'text', text: 'start' }]) await entered.promise expect(agent.status).toBe('running') const content = [{ type: 'text' as const, text: 'accepted-steer' }] @@ -991,7 +991,7 @@ describe('turn numbering continues across seeded sessions', () => { const turns: number[] = [] ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) - forked.send([{ type: 'text', text: 'continue' }]) + forked.followup([{ type: 'text', text: 'continue' }]) await new Promise((resolve) => { ctx2.on('agent/status', (subject, status) => { if (subject === forked && status === 'idle') resolve() diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index ebc2287a83..f8ff5a74b9 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -38,7 +38,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } describe('inbox acceptance', () => { @@ -50,10 +50,10 @@ describe('inbox acceptance', () => { ctx.on('agent/inbox/enqueue', () => { queued += 1 }) expect(() => { - agent.send([{ type: 'text', text: 'first', bad: 1n } as never]) + agent.followup([{ 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 }) + agent.followup([{ 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) diff --git a/packages/core/agent-loop/tests/inbox-invariant.spec.ts b/packages/core/agent-loop/tests/inbox-invariant.spec.ts index b1634afdb9..0d907f26d7 100644 --- a/packages/core/agent-loop/tests/inbox-invariant.spec.ts +++ b/packages/core/agent-loop/tests/inbox-invariant.spec.ts @@ -55,7 +55,7 @@ describe('inbox FIFO-conservation invariant', () => { return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) @@ -80,7 +80,7 @@ describe('inbox FIFO-conservation invariant', () => { return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) @@ -110,7 +110,7 @@ describe('inbox FIFO-conservation invariant', () => { return { action: 'stop' as const } }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(discards).toEqual([1]) // the dropped steering item was reported @@ -142,7 +142,7 @@ describe('inbox FIFO-conservation invariant', () => { agent.steer([{ type: 'text', text: 'late' }], { source: { kind: 'plugin', plugin: 'late' } }) }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The prompt plus the late steer both enqueued; both are matched (the prompt diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index b96dd48dbe..de721b1653 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } function events(agent: Agent): SessionEvent[] { @@ -128,7 +128,7 @@ describe('agent/prompt-submit', () => { ? downstream : { ...downstream, content: [{ type: 'text', text: 'rewritten request' }] } }) - agent.send([{ type: 'text', text: 'original request' }], { + agent.followup([{ type: 'text', text: 'original request' }], { contexts: [{ content: [{ type: 'text', text: 'untrusted prefix' }], source: { kind: 'plugin', plugin: 'prefix' }, @@ -203,7 +203,7 @@ describe('agent/prompt-submit', () => { const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) - agent.send([{ type: 'text', text: 'do something' }], { + agent.followup([{ type: 'text', text: 'do something' }], { contexts: [{ content: [{ type: 'text', text: 'must be dropped' }], source: { kind: 'plugin', plugin: 'test' } }], }) await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 8b8a4c8c92..8875b51d67 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } describe('agent loop', () => { @@ -539,7 +539,7 @@ describe('agent loop', () => { })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }], { meta: { prompt: 1 } }) + agent.followup([{ type: 'text', text: 'go' }], { meta: { prompt: 1 } }) await waitForIdle(ctx, agent) const user = agent.session.events.find(e => e.type === 'user/message') @@ -1083,9 +1083,9 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'user message' }]) + agent.followup([{ type: 'text', text: 'user message' }]) await Promise.resolve() - agent.send( + agent.followup( [{ type: 'text', text: 'plugin message' }], { source: { kind: 'plugin', plugin: 'test' } }, ) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 85efda0e4e..593b32ab38 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -115,7 +115,7 @@ describe('agent loop scheduling properties', () => { const { seen: trace } = recordStatus(ctx, agent) const idle = nextIdle(ctx, agent) // Send all in one synchronous tick: they queue before the loop wakes. - for (const text of texts) agent.send([{ type: 'text', text }]) + for (const text of texts) agent.followup([{ type: 'text', text }]) await idle // No message lost: every send appears as a user/message, in order. @@ -142,7 +142,7 @@ describe('agent loop scheduling properties', () => { const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) for (const text of texts) { const idle = nextIdle(ctx, agent) - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) await idle } // Each send was drained at a separate turn start: N turns, 1..N. @@ -171,7 +171,7 @@ describe('agent loop scheduling properties', () => { for (const step of steps) { const idle = nextIdle(ctx, agent) lastIdle = idle - agent.send([{ type: 'text', text: step.text }]) + agent.followup([{ type: 'text', text: step.text }]) if (step.settle) await idle } await lastIdle diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index f1e1a1a367..37079b8565 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -73,10 +73,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits ( const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) // Turn 1: forces a tool call → at least two steps (two model requests). - agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }]) + agent.followup([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }]) await waitForIdle(ctx, agent) // Turn 2: a follow-up over the same (longer) prefix. - agent.send([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }]) + agent.followup([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }]) await waitForIdle(ctx, agent) const usages = [...agent.session.events] diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 0efc76364a..a73218f345 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } /** Assert `previous` is a strict value-prefix of `current`. */ diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index 6f26d755a4..1fc44e3431 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -114,7 +114,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function send(agent: Agent): void { - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) } function contextError(message = 'context too large'): LlmError { diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 5c64bb92f9..2d8826868c 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -125,7 +125,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -153,7 +153,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent expect(sources1).toEqual(['startup']) - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -459,7 +459,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) // Let inject()'s fire-and-forget flush settle (NO explicit flush/dispose). @@ -482,7 +482,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) await ctx1.sessions.flush(a1.session) @@ -510,7 +510,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('first answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent - a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] const seqs1 = events1.map(e => e.seq) @@ -537,7 +537,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages()) // …and a new turn continues numbering (turn 2) with contiguous seqs. - a2.send([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } }) + a2.followup([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } }) await waitForIdle(ctx2, a2) const allSeqs = a2.session.events.map(e => e.seq) expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 469a815d8b..8844e5c1a3 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -203,11 +203,11 @@ describe('agent scope lifecycle', () => { if (event.type === 'user/message') heard.push('a-sees:user-message') }) - b.send(text('for b')) + b.followup(text('for b')) await waitForIdle(ctx, b) expect(heard).toEqual([]) // nothing of b's leaked into a's scope - a.send(text('for a')) + a.followup(text('for a')) await waitForIdle(ctx, a) expect(heard).toContain('a-sees:a:running') expect(heard).toContain('a-sees:user-message') @@ -934,7 +934,7 @@ describe('agent scope lifecycle', () => { if (event.type === 'turn/start') { off(); resolve() } }) }) - agent.send(text('work')) + agent.followup(text('work')) await turnOpen await owner.dispose() expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true']) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 6597a3710e..0e7f1313cb 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -105,7 +105,7 @@ describe('tool-call scheduler: grouping and barriers', () => { ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 3) expect(gated.started).toEqual(['1', '2', '3']) gated.release('1'); gated.release('2'); gated.release('3') @@ -133,7 +133,7 @@ describe('tool-call scheduler: grouping and barriers', () => { async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] }, })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3']) @@ -169,7 +169,7 @@ describe('tool-call scheduler: grouping and barriers', () => { })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => replacement.started.length === 1) await new Promise(r => setTimeout(r, 5)) expect(replacement.started).toEqual(['1']) @@ -200,7 +200,7 @@ describe('tool-call scheduler: grouping and barriers', () => { }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => initial.started.length === 2) initial.release('1') await until(() => events(agent).some(event => @@ -226,7 +226,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) gated.release('2') await new Promise(r => setTimeout(r, 5)) @@ -248,7 +248,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) gated.release('2'); gated.release('1') await waitForIdle(ctx, agent) @@ -294,7 +294,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) await new Promise(r => setTimeout(r, 5)) expect(gated.started).toEqual(['1', '2']) @@ -323,7 +323,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) await new Promise(r => setTimeout(r, 5)) expect(gated.started).toEqual(['1']) @@ -349,7 +349,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) await new Promise(r => setTimeout(r, 5)) expect(gated.started).toEqual(['1']) @@ -376,7 +376,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = ctx.on('tools/post-execute', async (exec, _result, next): Promise => { post.push(String(exec.callId)); return next() }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 3) gated.release('3'); gated.release('2'); gated.release('1') await waitForIdle(ctx, agent) @@ -397,7 +397,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = ({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) gated.release('2'); gated.release('1') await waitForIdle(ctx, agent) @@ -435,7 +435,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) gated.release('1') await waitForIdle(ctx, agent) @@ -465,7 +465,7 @@ describe('tool-call scheduler: abort handling', () => { } }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(gated.started).toEqual([]) @@ -497,7 +497,7 @@ describe('tool-call scheduler: abort handling', () => { return next() }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(gated.started).toEqual([]) @@ -527,7 +527,7 @@ describe('tool-call scheduler: abort handling', () => { })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) agent.cancel({ kind: 'user' }) gated.release('1') @@ -578,7 +578,7 @@ describe('tool-call scheduler: abort handling', () => { })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) agent.cancel({ kind: 'user' }) gated.release('1') diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 76b14e92c2..a39961bfbf 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -58,7 +58,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf const ctx = await harness(adapter, toolOrder) for (const name of registrationOrder) registerNamed(ctx, name) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { ctx, agent, adapter } } @@ -100,7 +100,7 @@ describe('loop-level canonical tool order', () => { const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha']) diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index eb6e6b4da5..44e1fd8a7b 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -34,7 +34,7 @@ async function harness(adapter: MockAdapter): Promise { } function send(agent: Agent, text = 'go'): Promise { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) return agent.whenIdle() } @@ -116,7 +116,7 @@ describe('agent/turn-stop', () => { ctx.on('session/flush', (session) => { if (session !== agent.session || queued) return queued = true - agent.send([{ type: 'text', text: 'ordinary queued follow-up' }]) + agent.followup([{ type: 'text', text: 'ordinary queued follow-up' }]) }) await send(agent) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index b09334d873..511ce32235 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,13 +54,13 @@ Turn and step boundaries and the model token stream are durable `session/event` ### Agent interface (`types.ts`) -`Agent` is a structural interface. `send()`, `queue()`, `steer()`, and `inject()` name common caller intents; `acceptInput(ResolvedAgentInput)` exposes the same acceptance path when a caller already has exact routing facts ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). Every `ResolvedAgentInput` field is mandatory, and its discriminated union excludes attached contexts from non-waking next-step injection. FIFO acceptance returns an opaque `AgentMessageId` carried by that item's `agent/inbox/enqueue`/`dequeue`/`discard` events. The driver snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. The helpers apply defaults: omitting `options.source` on `send()`, `queue()`, or `steer()` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content. +`Agent` is a structural interface. `followup()`, `queue()`, `steer()`, and `inject()` name common caller intents; `send(ResolvedAgentInput)` exposes the same acceptance path when a caller already has exact routing facts ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). Every `ResolvedAgentInput` field is mandatory, and its discriminated union excludes attached contexts from non-waking next-step injection. FIFO acceptance returns an opaque `AgentMessageId` carried by that item's `agent/inbox/enqueue`/`dequeue`/`discard` events. The driver snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. The helpers apply defaults: omitting `options.source` on `followup()`, `queue()`, or `steer()` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content. -- `agent.send(content, options?)` — queue one independent FIFO message as its own turn and wake the driver. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.followup(content, options?)` — queue one independent FIFO message as its own turn and wake the driver. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. - `agent.queue(content, options?)` — queue the same ordinary message without waking an idle driver. A lone queued item leaves `whenIdle()` resolved and rides along before the next waking message. - `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; while idle, create a waking ordinary turn. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `InjectOptions` deliberately has no attached contexts. `options.meta` persists opaque JSON state without rendering it. While a turn is open the injection joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. -- `agent.acceptInput(input)` — accept a fully specified route without helper defaults. `next-turn` targets the ordinary FIFO; `next-step` with wakeup targets steering and falls back to a waking ordinary turn while idle; `next-step` without wakeup is injection and requires `contexts: []`. Callers provide `meta: undefined` explicitly when they have no metadata. +- `agent.send(input)` — accept a fully specified route without helper defaults. `next-turn` targets the ordinary FIFO; `next-step` with wakeup targets steering and falls back to a waking ordinary turn while idle; `next-step` without wakeup is injection and requires `contexts: []`. Callers provide `meta: undefined` explicitly when they have no metadata. - `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `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.session`, `agent.status`, `agent.options`, `agent.id` @@ -79,7 +79,7 @@ Turn and step boundaries and the model token stream are durable `session/event` #### What the model sees -The four helpers and `acceptInput` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself. +The four intent helpers and fully resolved `send` path feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself. #### Token effect diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 4a959bf79a..0f442e3f88 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -27,7 +27,7 @@ export interface AgentOptions { } /** - * Options for {@link Agent.send}, {@link Agent.queue}, and {@link Agent.steer}. + * Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}. * An omitted source attests direct human input as `{ kind: 'user' }` and may * authorize policy consumers, so non-human producers must label their content. */ @@ -68,7 +68,7 @@ export function AgentMessageId(id: string): AgentMessageId { /** * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` - * is the value returned by the accepting helper or {@link Agent.acceptInput}, + * is the value returned by the accepting helper or {@link Agent.send}, * stable across this message's enqueue, dequeue, and discard events. Source * defaults, when applicable, are already applied, so these are the exact values * the item was accepted with. @@ -78,7 +78,7 @@ export function AgentMessageId(id: string): AgentMessageId { * `steering/message`, not live-event routing data. */ export interface AgentMessage { - /** The id returned by the accepting helper or {@link Agent.acceptInput}. */ + /** The id returned by the accepting helper or {@link Agent.send}. */ id: AgentMessageId content: ContentBlock[] source: MessageSource @@ -122,7 +122,7 @@ export interface HookContext { } /** - * Fully specified input for {@link Agent.acceptInput}. Unlike the intent-named + * Fully specified input for {@link Agent.send}. Unlike the intent-named * helpers, this form applies no defaults: callers provide content, source, * contexts, metadata (including explicit `undefined`), target, and wakeup. * The union excludes attached contexts from non-waking next-step injection. @@ -201,7 +201,7 @@ export interface Agent { * @param options - source, attached contexts, and durable model-hidden meta. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - send(content: ContentBlock[], options?: SendOptions): AgentMessageId + followup(content: ContentBlock[], options?: SendOptions): AgentMessageId /** * Queue an ordinary message without waking an idle driver. The item retains @@ -251,7 +251,7 @@ export interface Agent { * @param input - the resolved content, attribution, context, metadata, and routing facts. * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. */ - acceptInput(input: ResolvedAgentInput): AgentMessageId + send(input: ResolvedAgentInput): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -306,7 +306,7 @@ declare module 'cordis' { * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record * is the eventual `user/message`/`steering/message`. Injection through - * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs + * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs * and does not emit this. * @param agent - the agent whose inbox received the item. * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 9bdb634a2c..b5b0424957 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -28,11 +28,11 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { session: new Session(id), status: 'idle', ctx: new Context(), - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, ...overrides, @@ -50,7 +50,7 @@ describe('AgentRegistry', () => { expectTypeOf<'target' extends keyof SendOptions ? true : false>().toEqualTypeOf() expectTypeOf<'wakeup' extends keyof SendOptions ? true : false>().toEqualTypeOf() expectTypeOf<'contexts' extends keyof InjectOptions ? true : false>().toEqualTypeOf() - expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf['contexts']>() .toEqualTypeOf<[]>() diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 1ac506a098..e00bf2016d 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -235,7 +235,7 @@ describe('dsh-agent-spine-demo bundle', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) - handle.agent.send([{ type: 'text', text: 'recover' }]) + handle.agent.followup([{ type: 'text', text: 'recover' }]) await waitForIdle(ctx, handle.agent) expect(adapter.requests).toBe(2) @@ -335,7 +335,7 @@ describe('dsh-agent-spine-demo bundle', () => { }) const agent = handle.agent - agent.send([{ type: 'text', text: 'hi' }]) + agent.followup([{ type: 'text', text: 'hi' }]) await waitForIdle(ctx, agent) const sentText = adapter.requests[0]?.messages.map(messageText).join('\n') @@ -364,7 +364,7 @@ describe('dsh-agent-spine-demo bundle', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) - handle.agent.send([{ type: 'text', text: 'hi' }]) + handle.agent.followup([{ type: 'text', text: 'hi' }]) await waitForIdle(ctx, handle.agent) expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) @@ -454,7 +454,7 @@ describe('dsh-agent-spine-demo bundle', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) - handle.agent.send([{ type: 'text', text: 'hi' }]) + handle.agent.followup([{ type: 'text', text: 'hi' }]) await waitForIdle(ctx, handle.agent) expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills') diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 66ce345520..e6672f91ad 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -290,7 +290,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise try { /* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */ if (!settled) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition - agent.send([{ type: 'text', text: options.task }]) + agent.followup([{ type: 'text', text: options.task }]) } await turnEnded } finally { diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 165a084d52..c57dda5b00 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -471,7 +471,7 @@ describe('runOneShot and executeCli', () => { startup.ctx.on('session/event', (session, event) => { if (session === startup.agent.session && event.type === 'assistant/chunk') started() }) - startup.agent.send([{ type: 'text', text: 'first' }]) + startup.agent.followup([{ type: 'text', text: 'first' }]) await running const startupAbort = new AbortController() const waiting = runOneShot(startup.ctx, { task: 'second', signal: startupAbort.signal }) diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index 472b32f47a..b768813b22 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -36,7 +36,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => // (config.cwd = workdir) is the workspace. const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ type: 'text', text: + agent.followup([{ type: 'text', text: 'Create a file named note.txt containing exactly the line: status: draft. ' + 'Then read it back, then edit it to replace the literal word draft with final. ' + 'Tell me when done.' }]) @@ -68,7 +68,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => meta: { cwd: sessionDir }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, }) - handle.agent.send([{ type: 'text', text: + handle.agent.followup([{ type: 'text', text: 'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }]) await waitForIdle(ctx, handle.agent) diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 48c92293c8..a958659eba 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -48,11 +48,11 @@ function stubAgent(id: string): { agent: Agent; session: Session } { session, ctx: new Context(), get status() { return status }, - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') }, - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() { status = 'idle' }, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index a4883a0f7c..67cbd89d05 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -219,7 +219,7 @@ export function apply(ctx: Context): void { } state.attempt = reservation try { - agent.send(content, { + agent.followup(content, { source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round }, }) } catch (error: unknown) { diff --git a/packages/goal/goal-session/src/prompt.ts b/packages/goal/goal-session/src/prompt.ts index 9a2f69fcd8..d98f0bb83a 100644 --- a/packages/goal/goal-session/src/prompt.ts +++ b/packages/goal/goal-session/src/prompt.ts @@ -7,7 +7,7 @@ import type { GoalView } from '@deepseek-ai/dsh-goal' * Render the complete goal-round instruction retained in session history. * @param goal - exact active goal revision being admitted. * @param round - next positive round number. - * @returns a fresh one-block prompt for `Agent.send()`. + * @returns a fresh one-block prompt for `Agent.followup()`. */ export function renderGoalRoundPrompt(goal: GoalView, round: number): ContentBlock[] { return [{ diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 88f8ff202b..dc3d6723a7 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -276,7 +276,7 @@ describe('same-session goal driving', () => { ? Promise.resolve({ kind: 'block', reason: 'stop this round' }) : next()) test.ctx.on('goal/changed', (agent, change) => { - if (change.operation === 'block') agent.send([{ type: 'text', text: 'inspect the blocker' }]) + if (change.operation === 'block') agent.followup([{ type: 'text', text: 'inspect the blocker' }]) }) test.ctx.goals.create(test.agent, { objective: 'stop and inspect' }) @@ -323,7 +323,7 @@ describe('same-session goal driving', () => { it('lets already-queued human work finish before reserving the next round', async () => { const test = await harness([textResponse('human answer'), textResponse('goal answer')]) test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 }) - test.agent.send([{ type: 'text', text: 'human goes first' }]) + test.agent.followup([{ type: 'text', text: 'human goes first' }]) await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') @@ -364,7 +364,7 @@ describe('same-session goal driving', () => { test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return inserted = true - agent.send([{ type: 'text', text: 'human joined the pending batch' }]) + agent.followup([{ type: 'text', text: 'human joined the pending batch' }]) }) test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 }) @@ -479,16 +479,16 @@ describe('same-session goal driving', () => { expect(injectedTurn).toBeGreaterThan(goalTurn) }) - it('blocks the goal when a custom agent rejects the otherwise valid send', async () => { + it('blocks the goal when a custom agent rejects the otherwise valid follow-up', async () => { const test = await harness([]) - // Reject only the goal-sourced round send, not the state-change injection + // Reject only the goal-sourced round follow-up, not the state-change injection // that precedes it. - const realSend = test.agent.send.bind(test.agent) - vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { + const realFollowup = test.agent.followup.bind(test.agent) + vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => { if (options?.source?.kind === 'goal') { throw new Error('queue rejected') } - return realSend(content, options) + return realFollowup(content, options) }) test.ctx.goals.create(test.agent, { objective: 'handle queue failure' }) @@ -502,15 +502,15 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) }) - it('preserves a custom agent side effect when send disarms before throwing', async () => { + it('preserves a custom agent side effect when followup disarms before throwing', async () => { const test = await harness([]) - const realSend = test.agent.send.bind(test.agent) - vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { + const realFollowup = test.agent.followup.bind(test.agent) + vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => { if (options?.source?.kind === 'goal') { test.ctx.goals.disarm(test.agent) throw new Error('queue rejected after disarm') } - return realSend(content, options) + return realFollowup(content, options) }) test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' }) @@ -609,7 +609,7 @@ describe('same-session goal driving', () => { it('blocks forged goal attribution without touching an absent reservation', async () => { const test = await harness([]) - test.agent.send([{ type: 'text', text: 'forged automatic work' }], { + test.agent.followup([{ type: 'text', text: 'forged automatic work' }], { source: { kind: 'goal', goalId: GoalId('forged-goal'), revision: 1, round: 1 }, }) await test.agent.whenIdle() @@ -621,7 +621,7 @@ describe('same-session goal driving', () => { it('does not invent goal state when ordinary queued work is cancelled', async () => { const test = await harness([]) - test.agent.send([{ type: 'text', text: 'cancel ordinary work' }]) + test.agent.followup([{ type: 'text', text: 'cancel ordinary work' }]) test.agent.cancel({ kind: 'user' }) await test.agent.whenIdle() @@ -631,7 +631,7 @@ describe('same-session goal driving', () => { it('disarms without durably pausing when cancellation belongs to unrelated human work', async () => { const test = await harness(['hang']) - test.agent.send([{ type: 'text', text: 'inspect something first' }]) + test.agent.followup([{ type: 'text', text: 'inspect something first' }]) await waitForRequests(test.adapter, 1) const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' }) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 94c90cf192..264261d94b 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -64,7 +64,7 @@ function stubAgentForSession(session: Session): StubAgent { session, ctx: new Context(), get status() { return status }, - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { @@ -72,7 +72,7 @@ function stubAgentForSession(session: Session): StubAgent { else appendInjection(session, content, options) return AgentMessageId('stub') }, - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 3f286f8ec3..6e0b25c567 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -18,7 +18,7 @@ An autonomous goal round that successfully reports `complete` or `blocked` contr Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does. -`{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. +`{ kind: 'user' }` is a host attestation. `Agent.followup()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted and must describe it in `blocked_reason`. Direct human authority may stop a goal immediately. diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index 41fe713dc6..f48c9cd098 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -64,7 +64,7 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE /** * Whether host-attested human input appears in the current root-agent turn. - * An omitted `Agent.send()` / `steer()` source resolves to `user`, so non-human + * An omitted `Agent.followup()` / `steer()` source resolves to `user`, so non-human * producers must supply their own source rather than inheriting this authority. */ function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 3aaa06c447..bb795901ba 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -31,7 +31,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { session, get status() { return status }, ctx: new Context(), - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content: ContentBlock[], options?: InjectOptions) { @@ -43,7 +43,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 9f0247da72..d0a3214518 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -56,7 +56,7 @@ describe('threshold escalation', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -77,7 +77,7 @@ describe('threshold escalation', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -99,7 +99,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -123,7 +123,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(1) @@ -141,7 +141,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -162,7 +162,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -178,7 +178,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(1) // probe was NOT excluded @@ -194,7 +194,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(1) // all three canonicalize identically @@ -215,8 +215,8 @@ describe('chain semantics', () => { ])) const agentA = ctx.agentLoop.create(SessionId('a'), { provider: 'mock-a', model: 'model-a' }) const agentB = ctx.agentLoop.create(SessionId('b'), { provider: 'mock-b', model: 'model-b' }) - agentA.send([{ type: 'text', text: 'go' }]) - agentB.send([{ type: 'text', text: 'go' }]) + agentA.followup([{ type: 'text', text: 'go' }]) + agentB.followup([{ type: 'text', text: 'go' }]) await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)]) expect(reminders(agentA)).toHaveLength(0) // 2 repeats < 3, despite B's 3 in the same registry @@ -234,9 +234,9 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'again' }]) + agent.followup([{ type: 'text', text: 'again' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(0) @@ -256,13 +256,13 @@ describe('chain semantics', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) - first.send([{ type: 'text', text: 'go' }]) + first.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, first) await fiber.dispose() await first.whenIdle() const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) - second.send([{ type: 'text', text: 'go' }]) + second.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, second) expect(reminders(second)).toHaveLength(0) @@ -278,7 +278,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(1) @@ -294,7 +294,7 @@ describe('chain semantics', () => { textResponse('done'), ])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(0) @@ -316,7 +316,7 @@ describe('fold onto the downstream decision', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -347,7 +347,7 @@ describe('fold onto the downstream decision', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 6f829f8c82..f5fd5702d5 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -97,7 +97,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'do something' }]) + agent.followup([{ type: 'text', text: 'do something' }]) await waitForIdle(ctx, agent) // The prompt was blocked: model never called, turn ended rejected. @@ -120,7 +120,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The injected context reached the model and is recorded with the plugin source. @@ -145,7 +145,7 @@ describe('hooks-claude bridge — PreToolUse', () => { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'use danger' }]) + agent.followup([{ type: 'text', text: 'use danger' }]) await waitForIdle(ctx, agent) expect(ran).toBe(false) @@ -168,7 +168,7 @@ describe('hooks-claude bridge — PreToolUse', () => { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'use safe' }]) + agent.followup([{ type: 'text', text: 'use safe' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) @@ -190,7 +190,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const ctx = await harness(dir, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -211,7 +211,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const ctx = await harness(dir, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const log = events(agent) @@ -235,7 +235,7 @@ describe('hooks-claude bridge — PostToolUse', () => { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // `ask` degrades to deny today (FIXME permissions): the tool does not run and the result is isError. @@ -264,7 +264,7 @@ describe('hooks-claude bridge — SessionStart', () => { // fixed sleep that flakes under load. await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs')))) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('project uses tabs') @@ -357,7 +357,7 @@ describe('hooks-claude bridge — load resilience', () => { await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The turn ran normally — no hooks, no crash. expect(adapter.requests).toHaveLength(1) @@ -379,7 +379,7 @@ describe('hooks-claude bridge — load resilience', () => { await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 82461dcaa5..ab770479ed 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -74,7 +74,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string }, @@ -104,7 +104,7 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.logger.warn = warn as never ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) // substituted command ran }) @@ -120,7 +120,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let sawArgs: unknown ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // updatedInput is NOT honored — the tool ran with the ORIGINAL args. expect((sawArgs as { command?: string }).command).toBe('original') @@ -136,7 +136,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('ran')]) const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The prompt proceeded unchanged; no injected context. expect(adapter.requests).toHaveLength(1) @@ -166,7 +166,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) @@ -191,7 +191,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') @@ -207,7 +207,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please') @@ -223,7 +223,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // A second model request ran → the empty-reason block forced continuation. expect(adapter.requests).toHaveLength(2) @@ -271,7 +271,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) @@ -285,7 +285,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) @@ -314,7 +314,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const turnEnd = events(agent).findLast(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook') @@ -329,7 +329,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // ask (no reason) → degrades to deny with the registry's generic message. expect(ran).toBe(false) @@ -344,7 +344,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) @@ -369,7 +369,7 @@ export function defineCoverageCases(group: CoverageGroup): void { HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) }) @@ -384,7 +384,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) const res = events(agent).find(e => e.type === 'hook/result') @@ -399,7 +399,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) @@ -418,7 +418,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded @@ -435,7 +435,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) @@ -455,7 +455,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran }) @@ -473,7 +473,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // The factory create() path honors meta.cwd (the plain agentLoop.create() does not). const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent) expect(events(handle.agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) @@ -491,7 +491,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // A later listener that blocks every prompt (registered AFTER the bridge). ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // the downstream block won: the model was never called, no user/message was // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected` @@ -519,7 +519,7 @@ export function defineCoverageCases(group: CoverageGroup): void { }], })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) expect(req).toContain('from-bridge') @@ -547,7 +547,7 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) @@ -570,7 +570,7 @@ export function defineCoverageCases(group: CoverageGroup): void { }], })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') @@ -593,7 +593,7 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) @@ -617,7 +617,7 @@ export function defineCoverageCases(group: CoverageGroup): void { bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) @@ -640,7 +640,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await waitFor(() => threw) expect(threw).toBe(true) agent.inject = original - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject }) @@ -667,7 +667,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir @@ -717,7 +717,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) const warn = vi.fn(); ctx.logger.warn = warn as never const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) // Not surfaced: the systemMessage text never reaches the model request. @@ -736,7 +736,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Send immediately — do NOT wait for the session-start inject. - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing }) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 00c0e6c14d..923a4bf8b5 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -77,7 +77,7 @@ describe('hooks-codex bridge', () => { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'run ls' }]) + agent.followup([{ type: 'text', text: 'run ls' }]) await waitForIdle(ctx, agent) expect(ran).toBe(false) @@ -98,7 +98,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) @@ -115,7 +115,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('must not run')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'cancel the hook' }]) + agent.followup([{ type: 'text', text: 'cancel the hook' }]) await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) @@ -139,7 +139,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('fine')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) }) @@ -149,7 +149,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) }) @@ -169,7 +169,7 @@ describe('hooks-codex bridge', () => { await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 04602b3792..ebb2164902 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -65,7 +65,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null }, @@ -84,7 +84,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) const te = events(agent).findLast(e => e.type === 'turn/end') expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected') @@ -96,7 +96,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x') }) @@ -109,7 +109,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) expect(events(agent).some(e => e.type === 'user/message')).toBe(false) const te = events(agent).findLast(e => e.type === 'turn/end') @@ -131,7 +131,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro }], })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) expect(req).toContain('from-bridge') expect(req).toContain('from-downstream') @@ -154,7 +154,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) @@ -175,7 +175,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro }], })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ @@ -193,7 +193,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) @@ -208,7 +208,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx') }) @@ -219,7 +219,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) @@ -232,7 +232,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) }) }) @@ -246,7 +246,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' }) @@ -257,7 +257,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) @@ -270,7 +270,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis @@ -293,7 +293,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') }) @@ -316,7 +316,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook')) }) @@ -329,7 +329,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -344,7 +344,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the clean no-output hook has finished - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) }) @@ -370,7 +370,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -383,7 +383,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) }) @@ -399,7 +399,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) @@ -412,7 +412,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) }) @@ -424,7 +424,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) @@ -441,7 +441,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } expect(payload.tool_input.command).toBe('') }) @@ -477,7 +477,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) }) @@ -493,7 +493,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') }) @@ -506,7 +506,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook') }) @@ -534,7 +534,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale') }) @@ -547,7 +547,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') }) @@ -559,7 +559,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated') }) @@ -574,7 +574,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } expect(payload.tool_name).toBe('shell') expect(payload.tool_input.command).toBe('ls') @@ -590,7 +590,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(false) // the matcher fired → the hook denied the tool expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true) }) @@ -602,7 +602,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) const warn = vi.fn(); ctx.logger.warn = warn as never const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') }) @@ -625,7 +625,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true) diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 2d674912cd..f4d7bb2c1a 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -437,7 +437,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { if (mode === 'steer') agent.steer(content, { source }) - else agent.send(content, { source }) + else agent.followup(content, { source }) } catch (error: unknown) { // A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached. return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } }) diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index c30e07b50b..d86cb2ee5a 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -372,7 +372,7 @@ describe('sessions.prompt / cancel', () => { const { api, ctx } = running const { sessionId } = expectOk(await api.sessions.create(request({}))) const agent = ctx.agents.get(sessionId) as Agent - agent.send([{ type: 'text', text: 'run forever' }]) + agent.followup([{ type: 'text', text: 'run forever' }]) expectOk(await api.sessions.cancel(request({ sessionId }))) const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId })) @@ -391,7 +391,7 @@ describe('sessions.history', () => { const { sessionId } = expectOk(await first.api.sessions.create(request({}))) const agent = first.ctx.agents.get(sessionId) as Agent const idle = waitForIdle(first.ctx, agent) - agent.send([{ type: 'text', text: 'save me' }]) + agent.followup([{ type: 'text', text: 'save me' }]) await idle const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title') await first.dispose() @@ -440,7 +440,7 @@ describe('sessions.history', () => { const agent = ctx.agents.get(sessionId) as Agent for (const text of ['q1', 'q2', 'q3']) { const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) await idle } @@ -511,7 +511,7 @@ describe('events streams', () => { const agent = ctx.agents.get(sessionId) as Agent const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await idle const live = await stream.next() expect((live.value as RpcRequest).payload.type).toBe('session/event') @@ -574,7 +574,7 @@ describe('events streams', () => { const agent = ctx.agents.get(sessionId) as Agent const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'run' }]) + agent.followup([{ type: 'text', text: 'run' }]) await idle const runningFrame = await stream.next() expect((runningFrame.value as RpcRequest).payload).toMatchObject({ type: 'host/session-status', running: true }) diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 1aafc91d92..bb08577d74 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -114,7 +114,7 @@ describe('real Loader composition', () => { loaded.llm.registerAdapter(['mock'], adapter) const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(loaded, agent) - agent.send([{ type: 'text', text: 'recover' }]) + agent.followup([{ type: 'text', text: 'recover' }]) await idle expect(adapter.requests).toBe(2) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 4dc1c06bd6..20e7862ebc 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -129,7 +129,7 @@ describe('bounded transient retry policy', () => { }) }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) const event = await scheduled expect(event.data).toEqual({ @@ -178,7 +178,7 @@ describe('bounded transient retry policy', () => { const agent = context.agentLoop.create(SessionId('retry-partial'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await scheduled const idle = waitForIdle(context, agent) await vi.advanceTimersByTimeAsync(500) @@ -213,7 +213,7 @@ describe('bounded transient retry policy', () => { const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' }) const first = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) expect((await first).data.delayMs).toBe(450) const second = waitForRetry(context, agent, 2) @@ -246,7 +246,7 @@ describe('bounded transient retry policy', () => { const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) expect((await scheduled).data.delayMs).toBe(0) const idle = waitForIdle(context, agent) @@ -264,7 +264,7 @@ describe('bounded transient retry policy', () => { ;({ ctx: context } = await harness(accepted, { jitterRatio: 1 })) const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, acceptedAgent, 1) - acceptedAgent.send([{ type: 'text', text: 'go' }]) + acceptedAgent.followup([{ type: 'text', text: 'go' }]) expect((await scheduled).data.delayMs).toBe(2_000) const acceptedIdle = waitForIdle(context, acceptedAgent) await vi.advanceTimersByTimeAsync(2_000) @@ -278,7 +278,7 @@ describe('bounded transient retry policy', () => { ;({ ctx: context } = await harness(rejected)) const rejectedAgent = context.agentLoop.create(SessionId('retry-after-rejected'), { provider: 'mock', model: 'mock' }) const rejectedIdle = waitForIdle(context, rejectedAgent) - rejectedAgent.send([{ type: 'text', text: 'go' }]) + rejectedAgent.followup([{ type: 'text', text: 'go' }]) await rejectedIdle expect(rejected.requests).toHaveLength(1) expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) @@ -290,7 +290,7 @@ describe('bounded transient retry policy', () => { ;({ ctx: context } = await harness(adapter)) const agent = context.agentLoop.create(SessionId('retry-auth'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await idle expect(adapter.requests).toHaveLength(1) expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) @@ -307,7 +307,7 @@ describe('bounded transient retry policy', () => { context = mounted.ctx const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await scheduled const idle = waitForIdle(context, agent) @@ -335,7 +335,7 @@ describe('bounded transient retry policy', () => { model: 'mock', }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await entered.promise const disposing = mounted.retryFiber.dispose() @@ -376,7 +376,7 @@ describe('bounded transient retry policy', () => { model: 'mock', }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await captured.promise await mounted.retryFiber.dispose() @@ -397,7 +397,7 @@ describe('bounded transient retry policy', () => { ;({ ctx: context } = await harness(adapter)) const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await scheduled const idle = waitForIdle(context, agent) agent.cancel({ kind: 'user' }) @@ -426,7 +426,7 @@ describe('bounded transient retry policy', () => { const agent = context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await idle expect(adapter.requests).toHaveLength(1) @@ -450,7 +450,7 @@ describe('bounded transient retry policy', () => { }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await idle expect(adapter.requests).toHaveLength(1) diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 81aa7203c8..e195ed54b2 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -75,7 +75,7 @@ describe('plan mode through the agent loop', () => { // the first prompt-submit, BEFORE the first assembly. ctx.planMode.set(agent, true) - agent.send([{ type: 'text', text: 'explore the repo' }]) + agent.followup([{ type: 'text', text: 'explore the repo' }]) await waitForIdle(ctx, agent) const log = agent.session.events @@ -103,14 +103,14 @@ describe('plan mode through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'hello' }]) + agent.followup([{ type: 'text', text: 'hello' }]) await waitForIdle(ctx, agent) expect(foldPlanMode(agent.session.events)).toBe(false) const first = findEvent(agent.session.events, 'request/header') expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write']) ctx.planMode.set(agent, true) - agent.send([{ type: 'text', text: 'now plan' }]) + agent.followup([{ type: 'text', text: 'now plan' }]) await waitForIdle(ctx, agent) const log = agent.session.events @@ -146,7 +146,7 @@ describe('plan mode through the agent loop', () => { }) const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'plan after the transient failure' }]) + agent.followup([{ type: 'text', text: 'plan after the transient failure' }]) await recoveryEntered.promise ctx.planMode.set(agent, true) releaseRecovery.resolve(true) diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index f32dca3df6..26f4ddcc11 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -244,7 +244,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -287,7 +287,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 37e933d827..763ab5c871 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 09799ccecd..42622f2712 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -27,11 +27,11 @@ function stubAgent(ctx: Context, rawId: string): Agent { session: new Session(id), status: 'idle', ctx: scopeFiber.ctx, - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index a0ec66aff9..40477aeb7a 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index c974ad90db..e0b854ee43 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts b/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts index 89cd254c8e..412ff8203d 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts @@ -56,5 +56,5 @@ const handle = await ctx.agents.create({ sessionId: SessionId('semantic-checkpoint-crash'), agentOptions: { provider: 'crash', model: 'crash' }, }) -handle.agent.send([{ type: 'text', text: 'exercise the crash boundary' }]) +handle.agent.followup([{ type: 'text', text: 'exercise the crash boundary' }]) await waitForCrash() diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 6f091c2bf6..6882d324fc 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -64,7 +64,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { ]) // Parent does one real turn first, so the fork has a completed turn to seed. - parent.send([{ type: 'text', text: 'parent q1' }]) + parent.followup([{ type: 'text', text: 'parent q1' }]) await parent.whenIdle() const parentPrefixLen = parent.session.events.length @@ -93,7 +93,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { await forkRun.dispose() // The parent is unaffected and keeps working after both delegations. - parent.send([{ type: 'text', text: 'parent q2' }]) + parent.followup([{ type: 'text', text: 'parent q2' }]) await parent.whenIdle() const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message') expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two') diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 046c6ee8fe..cf5848e625 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -89,9 +89,9 @@ describe('dsh-subagent-fork', () => { it('seeds every completed parent turn through the last turn/end', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')]) - parent.send([{ type: 'text', text: 'q1' }]) + parent.followup([{ type: 'text', text: 'q1' }]) await parent.whenIdle() - parent.send([{ type: 'text', text: 'q2' }]) + parent.followup([{ type: 'text', text: 'q2' }]) await parent.whenIdle() const parentPrefixLen = parent.session.events.length @@ -108,7 +108,7 @@ describe('dsh-subagent-fork', () => { // Parent runs one turn, then we fork. The child's seeded log should contain // the parent's first turn, and the child should run its own new turn on top. const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) - parent.send([{ type: 'text', text: 'parent question' }]) + parent.followup([{ type: 'text', text: 'parent question' }]) await parent.whenIdle() const parentPrefixLen = parent.session.events.length @@ -137,10 +137,10 @@ describe('dsh-subagent-fork', () => { // open (a hanging model call), and fork while it's in flight. The seed must stop after the // balanced first turn; including the open turn would fail invariant replay during start. const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')]) - parent.send([{ type: 'text', text: 'q1' }]) + parent.followup([{ type: 'text', text: 'q1' }]) await parent.whenIdle() // Start a second turn that hangs (open turn/start + open step, never ends). - parent.send([{ type: 'text', text: 'q2' }]) + parent.followup([{ type: 'text', text: 'q2' }]) await new Promise(r => setTimeout(r, 20)) // let the hanging turn open // Forking now must NOT throw (the open second turn is excluded from the seed). @@ -164,7 +164,7 @@ describe('dsh-subagent-fork', () => { textResponse('parent turn'), toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }), ]) - parent.send([{ type: 'text', text: 'warm up' }]) + parent.followup([{ type: 'text', text: 'warm up' }]) await parent.whenIdle() const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'report structured' }], @@ -183,7 +183,7 @@ describe('dsh-subagent-fork', () => { // `readResult` must scan only child-owned events after the seed. The child emits no assistant // message, so scanning the whole log would incorrectly return the parent's distinctive text. const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop]) - parent.send([{ type: 'text', text: 'parent question' }]) + parent.followup([{ type: 'text', text: 'parent question' }]) await parent.whenIdle() const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 7776b2af3f..48c72e9cf0 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -11,7 +11,7 @@ The driver follows this sequence: 1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header. 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. -4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.send(prompt)` followed by `child.whenIdle()`. +4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. 5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns. The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index e83b397bb5..71bc7e42a6 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -141,7 +141,7 @@ export async function startInProcessRun( const result: Promise = (async () => { try { - child.send(request.prompt) + child.followup(request.prompt) await child.whenIdle() return readResult( child, diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 1c08488fc2..bb4cf32544 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -522,7 +522,7 @@ describe('in-process structured output', () => { textResponse('parent answer'), toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), ]) - parent.send([{ type: 'text', text: 'hello' }]) + parent.followup([{ type: 'text', text: 'hello' }]) await parent.whenIdle() expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) @@ -538,7 +538,7 @@ describe('in-process structured output', () => { describe('scoped registration (each child owns its capture tool)', () => { it('a plain agent never sees the tool: nothing is registered globally at all', async () => { const { ctx, parent, adapter } = await setup([textResponse('parent answer')]) - parent.send([{ type: 'text', text: 'hello' }]) + parent.followup([{ type: 'text', text: 'hello' }]) await parent.whenIdle() // Scoped registration: the global view has no capture tool, ever. expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() @@ -552,7 +552,7 @@ describe('in-process structured output', () => { // Child turn: must see it, with the run's schema. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }), ]) - parent.send([{ type: 'text', text: 'hello' }]) + parent.followup([{ type: 'text', text: 'hello' }]) await parent.whenIdle() expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL) @@ -630,7 +630,7 @@ describe('in-process structured output', () => { it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => { const { parent, adapter } = await setup([textResponse('plain')]) - parent.send([{ type: 'text', text: 'q' }]) + parent.followup([{ type: 'text', text: 'q' }]) await parent.whenIdle() const request = adapter.requests[0]! expect(request.tools).toBeUndefined() diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index a1401bbb6d..ce91480ea8 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -87,7 +87,7 @@ describe('startInProcessRun', () => { it('seeds a forked child but reads only the child-owned output', async () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) - parent.send([{ type: 'text', text: 'parent question' }]) + parent.followup([{ type: 'text', text: 'parent question' }]) await parent.whenIdle() const seed = parent.session.events.slice() const run = await startInProcessRun(request(parent), { seed }) diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts index 89efb2b815..8fbecdc976 100644 --- a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -31,7 +31,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( ctx = await spawnHarness(workdir) const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - parent.send([{ type: 'text', text: + parent.followup([{ type: 'text', text: 'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text ' + 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." ' + 'After the subagent finishes, tell me it is done.' }]) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 9fcdbee5e7..c83eca75ab 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -95,7 +95,7 @@ describe('dsh-subagent-spawn', () => { it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => { // Drive the parent through one real turn so it has history, THEN spawn. const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')]) - parent.send([{ type: 'text', text: 'parent prompt' }]) + parent.followup([{ type: 'text', text: 'parent prompt' }]) await parent.whenIdle() const parentEventCount = parent.session.events.length expect(parentEventCount).toBeGreaterThan(0) @@ -372,7 +372,7 @@ describe('dsh-subagent-spawn', () => { textResponse('parent answer'), textResponse('child answer'), ]) - parent.send([{ type: 'text', text: 'hi' }]) + parent.followup([{ type: 'text', text: 'hi' }]) await parent.whenIdle() const run = await start(ctx, 'spawn', { diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index e06fe01aa5..015f1c4f2b 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -23,11 +23,11 @@ function stubAgent(ctx: Context, rawId: string): Agent { session: new Session(id), status: 'idle' as const, ctx: scopeFiber.ctx, - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 869c1183f2..fe09af9730 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -59,7 +59,7 @@ describe('todo_write tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-todo'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'plan a two-step task' }]) + agent.followup([{ type: 'text', text: 'plan a two-step task' }]) await waitForIdle(ctx, agent) const log = agent.session.events @@ -87,7 +87,7 @@ describe('todo_write tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-todo-2'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'plan then update' }]) + agent.followup([{ type: 'text', text: 'plan then update' }]) await waitForIdle(ctx, agent) const todoEvents = agent.session.events.filter(e => e.type === 'todo/write') diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 3a8576df74..f135e42553 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -29,7 +29,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: | `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected | | `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, tool, and title events, and re-advertises commands | | `session/list` | `ctx.sessionQuery` | returns live-preferred newest-first sessions with absolute cwd and optional folded title; supports exact normalized cwd filtering, returns no cursor, and rejects supplied cursors | -| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC | +| `session/prompt` | `ctx.commands.execute()` or `agent.followup()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC | | `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another | | `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, tool render intents, and `session_info_update` title revisions | | `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 55d44613c5..3292ee17c4 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -23,7 +23,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i | `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. | | `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. | | `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. | -| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. | +| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.followup`. One request is in flight per session. | | `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. | | `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-plan-mode` mounted, `session/new`/`session/load` advertise the fixed `default` / `plan` projection and `session/set_mode` records the boolean pending intent (optimistic `current_mode_update`; logged `plan/mode` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). | | `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. | diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 3330c582b4..dcc32f1c6f 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1056,7 +1056,7 @@ export function apply(ctx: Context, config: AcpConfig): void { } const { text } = referencedPrompt let preparedContent: ContentBlock[] = [{ type: 'text', text }] - let preparedContexts: NonNullable[1]>['contexts'] = [] + let preparedContexts: NonNullable[1]>['contexts'] = [] if (referencedPrompt.references.length > 0) { const sessionReferences = ctx.get('sessionReferences') if (sessionReferences === undefined) { @@ -1081,14 +1081,14 @@ export function apply(ctx: Context, config: AcpConfig): void { } assertOpen() } - // Install the in-flight slot BEFORE send() (send does not synchronously + // Install the in-flight slot BEFORE followup() (followup does not synchronously // flip status to running; the session/event listener records the turn // number and settle/rejects it). Capture the log length now as the // A turn that ends in error rejects this promise (the codec never // produces an error stop reason). const stopReason = await new Promise((resolve, reject) => { rec.inflight = { resolve, reject, turn: undefined } - rec.agent.send(preparedContent, { contexts: preparedContexts }) + rec.agent.followup(preparedContent, { contexts: preparedContexts }) }) return { stopReason } }, diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index 8baf076d58..bcfb177b16 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -264,7 +264,7 @@ describe('acp bridge — disposal & HMR safety', () => { const handle = await harness.ctx.agents.create({ sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' }, }) - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await handle.agent.whenIdle() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined() @@ -288,7 +288,7 @@ describe('acp bridge — disposal & HMR safety', () => { // Drive a turn that hangs in the model stream, so the loop is mid-turn when // disposed — its exit runs a final session/flush we can gate to hold the // teardown observably in-flight. - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) expect(handle.agent.status).toBe('running') let releaseFlush!: () => void diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index fdbaf241e6..ac85fbd7e0 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -30,7 +30,7 @@ describe('acp bridge — demux & config edges', () => { const before = harness.updates.length const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } }) - foreign.send([{ type: 'text', text: 'hi' }]) + foreign.followup([{ type: 'text', text: 'hi' }]) await foreign.whenIdle() await new Promise(r => setTimeout(r, 10)) diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 65ca3ed52c..5200a2de13 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -152,7 +152,7 @@ export class HarnessSdkServer { rec.activePrompt = true try { rec.lastTurnEnd = undefined - rec.handle.agent.send(params.contentBlocks) + rec.handle.agent.followup(params.contentBlocks) await rec.handle.agent.whenIdle() const status = this.finishedStatus(rec.lastTurnEnd) this.transport.notify('session.finished', { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 5105023581..43dc690c72 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -152,7 +152,7 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir }, agentOptions: { provider: 'deepseek', model: 'dsagent-model' }, }) - orphanHandle.agent.send([{ type: 'text', text: 'outside the sdk session map' }]) + orphanHandle.agent.followup([{ type: 'text', text: 'outside the sdk session map' }]) await orphanHandle.agent.whenIdle() await orphanHandle.dispose() expect(llmServer.requests).toHaveLength(3) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 21601fa280..2951ce612b 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -18,9 +18,9 @@ Before model output, session events, tool presenters, questions, configuration, Typing `@` at a token boundary searches files and directories under the session working directory. A bare fuzzy query uses a reusable bounded workspace index; a query containing `/` lists that directory directly, and selecting a folder keeps completion open for descent. Whitespace-bearing paths are inserted as `@"path with spaces"`. Selecting a file inserts only its path and a trailing space: the TUI does not read it, attach hidden context, or replace it with a reference object. When a model-facing `read` tool is registered, the TUI adds one fixed system-prompt instruction telling the model to read an explicit path when its contents are needed. -When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.send()` from the status after that asynchronous preparation, so idle sends still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. +When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. @@ -77,7 +77,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic #### What the model sees -Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`. +Each non-empty ordinary editor submission becomes one text block, sent with `agent.followup()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`. #### Token effect @@ -125,7 +125,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr #### What the model sees -A `/skill: [instructions]` submission loads the named skill and delivers one text block: a `` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same send-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. +A `/skill: [instructions]` submission loads the named skill and delivers one text block: a `` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. #### Token effect diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index aef8ffc403..a97754d8cb 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -2524,7 +2524,7 @@ export function createTuiChat( } else if (agent.status === 'running') { agent.steer(content, { contexts }) } else { - agent.send(content, { contexts }) + agent.followup(content, { contexts }) } } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 6bf6fcb20d..a111621e34 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -150,7 +150,7 @@ export async function createTuiTestHarness AgentMessageId('stub'), - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel(cause = { kind: 'user' }) { cancelled.push(cause) }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 623f85a3d4..725369f3ca 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2499,7 +2499,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) @@ -2523,7 +2523,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -2557,14 +2557,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -2594,7 +2594,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -2636,7 +2636,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index dcc3b947fb..c9492b9450 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -70,7 +70,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) const parent = parentHandle.agent - parent.send([{ type: 'text', text: 'PARENT_PROMPT_MARKER' }]) + parent.followup([{ type: 'text', text: 'PARENT_PROMPT_MARKER' }]) await parent.whenIdle() const children: Agent[] = [] diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index 7c61b9395d..5f2080b149 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -30,8 +30,45 @@ function quote(value: string): string { } /** - * Collect exported interface and type shapes; omit names declared in multiple - * packages rather than risk serving the wrong package's shape. + * Reduce an exported class to its type shape: drop method/constructor bodies + * and property initializers so the catalog serves member signatures, not + * implementation. + */ +function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration { + const isNonPublic = (member: ts.ClassElement): boolean => + (ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some(m => + m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false + const members = node.members.flatMap((member): ts.ClassElement[] => { + if (isNonPublic(member) || (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name))) return [] + if (ts.isMethodDeclaration(member)) { + return [ts.factory.updateMethodDeclaration( + member, member.modifiers, member.asteriskToken, member.name, member.questionToken, + member.typeParameters, member.parameters, member.type, undefined)] + } + if (ts.isConstructorDeclaration(member)) { + return [ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined)] + } + if (ts.isGetAccessorDeclaration(member)) { + return [ts.factory.updateGetAccessorDeclaration( + member, member.modifiers, member.name, member.parameters, member.type, undefined)] + } + if (ts.isSetAccessorDeclaration(member)) { + return [ts.factory.updateSetAccessorDeclaration( + member, member.modifiers, member.name, member.parameters, undefined)] + } + if (ts.isPropertyDeclaration(member)) { + return [ts.factory.updatePropertyDeclaration( + member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined)] + } + return [member] + }) + return ts.factory.updateClassDeclaration( + node, node.modifiers, node.name, node.typeParameters, node.heritageClauses, members) +} + +/** + * Collect exported interface, type-alias, and body-stripped class shapes; omit + * names declared in multiple packages rather than serve the wrong shape. */ function collectTypeDecls(scanRoot: string = root): Map { const printer = ts.createPrinter({ removeComments: true }) @@ -41,14 +78,16 @@ function collectTypeDecls(scanRoot: string = root): Map { const abs = resolve(scanRoot, rel) const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true) for (const stmt of sf.statements) { - if (!ts.isInterfaceDeclaration(stmt) && !ts.isTypeAliasDeclaration(stmt)) continue + const named = ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isClassDeclaration(stmt) + if (!named || stmt.name === undefined) continue if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) continue const name = stmt.name.text if (decls.has(name)) { ambiguous.add(name) continue } - const printed = printer.printNode(ts.EmitHint.Unspecified, stmt, sf).replace(/\r/g, '') + const emit = ts.isClassDeclaration(stmt) ? classShape(stmt) : stmt + const printed = printer.printNode(ts.EmitHint.Unspecified, emit, sf).replace(/\r/g, '') decls.set(name, printed.length > MAX_DECL_CHARS ? `${printed.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */` : printed) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 26ebfa8db7..1f54f7358f 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -918,7 +918,7 @@ function renderLifecycle(): string { ' participant Session', ' participant Persistence', ' participant SDK as UI or SDK listener', - ' User->>Agent: send(content)', + ' User->>Agent: followup(content)', ` Agent-->>SDK: ${mermaidCode('agent/inbox/enqueue')}`, ' Agent->>Driver: queued work wakes driver', ` Driver-->>SDK: ${mermaidCode('agent/status')} running`, From be29b012f1209af37d4adabb69ce0f0da72d1661 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 15:39:04 +0800 Subject: [PATCH 31/37] fix(jsonrpc): update agent followup test doubles --- packages/ui/jsonrpc/tests/server.spec.ts | 31 ++++++++++++------------ 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 43dc690c72..91d3503263 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' @@ -170,16 +170,16 @@ describe('HarnessSdkServer', () => { const mainWhenIdle = vi.fn<() => Promise>() .mockReturnValueOnce(firstMainIdle) .mockResolvedValue(undefined) - const mainSend = vi.fn() - const mainAgent = { - send: mainSend, + const mainFollowup = vi.fn().mockReturnValue(AgentMessageId('main-followup')) + const mainAgent = ({ + followup: mainFollowup, whenIdle: mainWhenIdle, - } as unknown as Agent - const otherSend = vi.fn() - const otherAgent = { - send: otherSend, + } satisfies Pick) as unknown as Agent + const otherFollowup = vi.fn().mockReturnValue(AgentMessageId('other-followup')) + const otherAgent = ({ + followup: otherFollowup, whenIdle: vi.fn(() => Promise.resolve()), - } as unknown as Agent + } satisfies Pick) as unknown as Agent const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) } const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) } const create = vi.fn(async (options: { sessionId: SessionId }) => @@ -196,7 +196,7 @@ describe('HarnessSdkServer', () => { }) const first = prompt('main', 'first') - await vi.waitFor(() => { expect(mainSend).toHaveBeenCalledOnce() }) + await vi.waitFor(() => { expect(mainFollowup).toHaveBeenCalledOnce() }) await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main') await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true }) @@ -208,8 +208,8 @@ describe('HarnessSdkServer', () => { await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed') await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true }) - expect(mainSend).toHaveBeenCalledTimes(4) - expect(otherSend).toHaveBeenCalledOnce() + expect(mainFollowup).toHaveBeenCalledTimes(4) + expect(otherFollowup).toHaveBeenCalledOnce() await server.shutdown() expect(mainHandle.dispose).toHaveBeenCalledOnce() expect(otherHandle.dispose).toHaveBeenCalledOnce() @@ -225,9 +225,9 @@ describe('HarnessSdkServer', () => { shutdown(): Promise> } const session = ctx.sessions.create(SessionId('message-outcome')) - const agent = { + const agent = ({ session, - send(content: { type: 'text'; text: string }[]) { + followup(content: { type: 'text'; text: string }[]) { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } }, @@ -246,9 +246,10 @@ describe('HarnessSdkServer', () => { source: { kind: 'plugin', plugin: 'late-metadata' }, }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + return AgentMessageId('message-outcome') }, whenIdle: () => Promise.resolve(), - } as unknown as Agent + } satisfies Pick) as unknown as Agent server.sessions.set('message-outcome', { handle: { agent, dispose: () => Promise.resolve() }, lastTurnEnd: undefined, From 31fb3a3f44ea75ced79a9191708d4b6ffd016e91 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 24 Jul 2026 16:08:54 +0800 Subject: [PATCH 32/37] docs(agent): propose context injection semantics --- ...xt-injection-from-turn-execution.i18n.yaml | 6 ++ ...e-context-injection-from-turn-execution.md | 75 +++++++++++++++++++ ...ontext-injection-from-turn-execution.zh.md | 75 +++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md create mode 100644 .agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml new file mode 100644 index 0000000000..233cc3901c --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-separate-context-injection-from-turn-execution.md: 652c3d410ab625d91a828f854bce302adcb0c9e0 +2026-07-24-separate-context-injection-from-turn-execution.zh.md: 1064e7a869ab9ea46c0145eb010119894a03aacf diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md new file mode 100644 index 0000000000..652c3d410a --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md @@ -0,0 +1,75 @@ +# Agent Note: Separate context injection from turn execution + +Status: proposed + +English | [中文](2026-07-24-separate-context-injection-from-turn-execution.zh.md) + +## Problem + +The agent API currently represents supplementary model-facing input in three overlapping ways: callers attach `HookContext[]` through `SendOptions.contexts`, interception and tool hooks return `additionalContexts`, and plugins call `agent.inject()`. These paths eventually write context into the same model history, but they carry different placement, metadata, admission, queue, and turn-lifecycle rules. + +Atomic attachment to an inbox message forces the loop to preserve context through prompt admission, steering conversion, cancellation, and terminal discard. `prompt-prefix` placement then combines context and the direct prompt into one event, requiring a model-hidden envelope so transcript consumers can recover what the user actually wrote. The result makes outbox entries, session projection, and UI replay responsible for a distinction that belongs to the producer. + +Idle `inject()` exposes a second mismatch. Injection does not request model execution, yet the current implementation opens and closes a zero-step `injection` turn solely to satisfy the turn-enclosure invariant and obtain a durability checkpoint. A turn therefore sometimes means “run the agent loop” and sometimes means “persist context without running it.” + +`HookContext` also names its producer rather than its role. The value may come from a native plugin, a hook bridge, prompt admission, or tool post-processing. Its stable meaning is simply additional model-facing context with provenance. + +## Proposal + +Make `inject()` the only caller-facing operation for adding supplementary model-facing input, and define a turn exclusively as one execution of the model loop. + +Remove `SendOptions.contexts`. A caller that owns context delivers it with `inject()` and independently submits the direct message with `send()` or `steer()`. Rename `HookContext` to `AdditionalContext`; retain only `content` and `source`, and remove placement and model-hidden metadata from this shared shape. + +Prompt and tool extension points may still return `additionalContexts`. These values are outputs of the extension point, not attachments captured from a caller's inbox item. Prompt admission runs before `run()` opens a turn. An allowed prompt enters the outbox together with its returned additional contexts; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the same outbox after the corresponding tool results. + +Every additional context becomes an independent `user/message` whose `source` records provenance. Remove `context/message`, prompt-prefix placement, the stable request delimiter, and the prompt envelope. Transcript and UI consumers distinguish direct user messages from injected context by `source`, not by recovering a hidden direct-prompt field from combined model content. + +## Injection lifecycle + +When a turn is open, `inject()` stages the context in the loop outbox. The loop drains the outbox at a safe step boundary, preserving tool protocol adjacency: a context accepted during an assistant tool-call batch appears only after that batch's complete ordered results. Taking the outbox as a whole makes steering and injected context accepted for one boundary visible to the same following request. + +When no turn is open, `inject()` appends its `user/message` immediately and starts a session flush. It does not increment turn numbering, emit `turn/start` or `turn/end`, change agent status, or run the model. The synchronous API still returns before the asynchronous flush settles; `whenIdle()` and agent disposal include outstanding idle-injection flushes in their quiescence boundary. + +A failed idle flush has no legitimate turn or step coordinates. It is reported through logging or a persistence-owned error surface, not by inventing an `agent/error` payload for a nonexistent turn. The in-memory event remains accepted and a later flush may retry persistence. + +The session invariant therefore permits `user/message` between turns while continuing to require turn enclosure for execution events, steering, assistant output, tools, and package-added events by default. Persistence, recovery, resume, fork, and compaction code must treat a valid out-of-turn `user/message` as committed session history rather than an interrupted or discardable turn tail. + +## Extension and caller semantics + +`PromptDecision.content` continues to replace only the direct prompt. `PromptDecision.additionalContexts` and tool-result `additionalContexts` retain FIFO order and individual provenance, but no longer select placement. A waterfall listener that delegates with `next()` must preserve downstream prompt content and additional contexts unless it intentionally returns replacements. + +Caller-driven injection and hook-produced additional context deliberately have different admission ownership. A hook's additional contexts materialize only after that hook allows the prompt or tool result. A caller that invokes `inject(context)` and then `send(prompt)` has already committed context independently; if prompt admission later blocks the prompt, the injected context remains in history. Callers requiring all-or-nothing domain behavior must perform their own preparation before either operation or expose a domain-specific admission seam. + +Cross-session references follow the ordinary composition: the host prepares the snapshot, injects it with session-reference provenance, then sends or steers the readable direct prompt. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../../implemented/feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules. + +This proposal preserves the caller-owned framing decision from [unwrapped injected content](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), the one-item turn rule from [one send, one turn](../../implemented/simplification/2026-07-17-one-send-one-turn.md), and narrows the [turn-enclosure decision](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) so turns enclose execution rather than every session event. + +## Alternatives considered + +**Keep `SendOptions.contexts` as an atomic attachment.** This preserves all-or-nothing delivery when prompt admission blocks, but it keeps context inside inbox lifecycle state and requires every queue transition and observation event to carry it. The generic agent API should not encode a domain transaction that most callers can express as context injection followed by message delivery. + +**Keep a distinct `context/message` session event.** A separate event makes the out-of-turn exception narrower, but user-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers. + +**Keep one-shot turns for idle injection.** This retains universal turn enclosure and a convenient flush boundary, but it makes turn counts and turn observers report work that never ran the model. Durability is an independent session concern and can be awaited without fabricating execution. + +**Keep `prompt-prefix` as an optional placement.** Prefix baking can make the context and request appear in one provider message, but it introduces a second representation of the direct prompt and spreads placement handling across admission, steering, logging, replay, and UI code. Producers that require textual framing may include it in their own context content. + +**Let hooks call `inject()` directly instead of returning additional contexts.** Direct injection would erase the extension point's admission ownership: a listener could append context before a downstream listener blocks the operation. Returning `additionalContexts` keeps the waterfall result authoritative while sharing the same post-admission outbox path. + +## Acceptance criteria + +- `SendOptions` and steering inbox records contain no attached contexts; `agent/queued` reports only the retained message and steering facts. +- `AdditionalContext` replaces `HookContext` across prompt interception, tool execution, hook bridges, guards, and context producers, with only `content` and `source`. +- Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay. +- Idle `inject()` appends and flushes one sourced `user/message` without a turn or model call; `whenIdle()` and disposal await the flush. +- Active-turn injection and hook-produced contexts drain at safe boundaries after complete tool-result batches and before the request that consumes them. +- Blocked prompt admission opens no turn and appends neither the prompt nor hook-produced additional contexts; independently injected caller context remains. +- Unit, persistence/resume, invariant, ACP/TUI replay, and keyless assembled-application snapshots cover the new event order and durability semantics. + +## Risks + +- Allowing one surface event outside turns weakens a simple invariant and may expose hidden assumptions in persistence scanning, crash repair, forking, compaction, and session queries. +- Consecutive user-role messages replace one baked prompt message; provider adapters and cache behavior must accept and preserve that ordering. +- `inject()` followed by a blocked `send()` leaves context without its intended direct prompt unless the caller accepts the independent-commit contract. +- A synchronous injection API cannot return flush failure. Logging alone is less structured than `agent/error`, while adding a new persistence event solely for this case may create another unnecessary seam. +- Removing attachment, placement, metadata, envelopes, and a durable event type is a broad pre-release migration that must update every producer and consumer atomically. diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md new file mode 100644 index 0000000000..1064e7a869 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md @@ -0,0 +1,75 @@ +# Agent Note: 将上下文注入与轮次执行分离 + +Status: proposed + +[English](2026-07-24-separate-context-injection-from-turn-execution.md) | 中文 + +## 问题 + +agent API 目前用三种相互重叠的方式表示面向模型的补充输入:调用方通过 `SendOptions.contexts` 附加 `HookContext[]`,拦截钩子和工具钩子返回 `additionalContexts`,插件则调用 `agent.inject()`。这些路径最终都会把上下文写入同一份模型历史,但各自携带不同的放置、元数据、准入、队列和轮次生命周期规则。 + +将上下文原子附加到收件箱消息后,agent loop(智能体循环)必须让上下文跟随提示词准入、steering(中途引导)转换、取消和终止丢弃的完整生命周期。`prompt-prefix` 放置方式又会把上下文与直接提示词合并为一个事件,因此 transcript(文本记录)消费方需要依赖模型不可见的封套,才能还原用户实际输入。这样一来,outbox 条目、会话投影和 UI 回放都必须处理本应由生产方负责的区分。 + +空闲状态下的 `inject()` 还暴露了另一处语义错位。注入并不请求模型执行,但当前实现仅为了满足轮次封闭不变量并获得持久性检查点,就会打开并关闭一个零步骤的 `injection` 轮次。于是,轮次有时表示「运行 agent loop」,有时却表示「不运行 agent,仅持久化上下文」。 + +`HookContext` 的名字也描述了生产方,而非该值的职责。它可能来自原生插件、hook bridge、提示词准入或工具后处理;其稳定含义只是带来源信息的额外模型上下文。 + +## 提案 + +将 `inject()` 设为调用方添加补充模型输入的唯一操作,并把轮次严格定义为一次模型循环执行。 + +移除 `SendOptions.contexts`。拥有上下文的调用方通过 `inject()` 交付上下文,再独立使用 `send()` 或 `steer()` 提交直接消息。将 `HookContext` 重命名为 `AdditionalContext`;这个共享结构只保留 `content` 和 `source`,移除放置方式与模型不可见元数据。 + +提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。提示词获准后,它与返回的额外上下文一同进入 outbox;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入同一个 outbox。 + +每项额外上下文都成为独立的 `user/message`,并由 `source` 记录来源。移除 `context/message`、prompt-prefix 放置方式、稳定请求分隔符和提示词封套。transcript 与 UI 消费方通过 `source` 区分直接用户消息和注入上下文,无需从合并后的模型内容中恢复隐藏的直接提示词字段。 + +## 注入生命周期 + +轮次打开时,`inject()` 将上下文暂存在 loop outbox 中。agent loop 会在安全的步骤边界排空 outbox,同时保持工具协议要求的相邻关系:在助手工具调用批次期间接纳的上下文,只能出现在该批次所有有序结果之后。系统整体取走 outbox,确保同一边界接纳的 steering 和注入上下文对后续同一次请求可见。 + +没有打开的轮次时,`inject()` 会立即追加对应的 `user/message` 并启动会话刷新。它不会增加轮次编号、发出 `turn/start` 或 `turn/end`、改变 agent 状态,也不会运行模型。同步 API 仍会在异步刷新完成前返回;`whenIdle()` 和 agent dispose(资源释放)会把尚未结束的空闲注入刷新纳入静止边界。 + +空闲刷新失败时不存在合法的轮次或步骤坐标。系统通过日志或持久化所属的错误接口报告该失败,而不是为不存在的轮次伪造 `agent/error` 载荷。内存中的事件仍已接纳,后续刷新可以重试持久化。 + +因此,会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求执行事件、steering、助手输出、工具事件以及默认的包扩展事件均受轮次边界约束。持久化、恢复、resume、fork、压缩和查询逻辑必须把合法的轮次外 `user/message` 当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。 + +## 扩展点与调用方语义 + +`PromptDecision.content` 仍只替换直接提示词。`PromptDecision.additionalContexts` 和工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源,但不再选择放置方式。waterfall(瀑布式事件)监听器调用 `next()` 委托时,必须保留下游返回的提示词内容和额外上下文,除非它有意返回替代值。 + +调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。调用方执行 `inject(context)` 后再执行 `send(prompt)` 时,上下文已独立提交;后续提示词准入即使阻止该提示词,注入上下文仍保留在历史中。需要领域级全有或全无语义的调用方,必须在执行任一操作前自行完成准备,或提供领域专用的准入 seam。 + +跨会话引用使用普通组合方式:宿主先准备快照,以会话引用来源调用 `inject()`,再发送或 steer 可读的直接提示词。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本提案取代[跨会话引用决策](../../implemented/feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 + +本提案保留[移除注入内容封套](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../../implemented/simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则;同时收窄[轮次封闭决策](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md),使轮次约束执行过程,而不是约束所有会话事件。 + +## 曾考虑的替代方案 + +**保留 `SendOptions.contexts` 作为原子附件。** 提示词准入阻止消息时,这种方式能保留全有或全无交付,但也会让上下文继续成为收件箱生命周期状态的一部分,并迫使每次队列转换和观察事件携带它。大多数调用方都可以通过先注入上下文、再交付消息来表达需求,通用 agent API 不应内置领域事务。 + +**保留独立的 `context/message` 会话事件。** 独立事件可以缩小轮次外事件的例外范围,但面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。 + +**为空闲注入保留一次性轮次。** 这种方式能保留通用轮次封闭和方便的刷新边界,却会让轮次计数与轮次观察方报告从未运行模型的工作。持久性是独立的会话关注点,无需伪造执行即可等待。 + +**保留 `prompt-prefix` 可选放置方式。** 前缀烘焙可以让上下文和请求位于同一条提供方消息中,但它会引入直接提示词的第二种表示,并把放置处理扩散到准入、steering、日志、回放和 UI 代码。需要文本框架的生产方可以直接把它写入自身上下文内容。 + +**让钩子直接调用 `inject()`,而不是返回额外上下文。** 直接注入会破坏扩展点的准入归属:下游监听器阻止操作之前,上游监听器就可能已经追加上下文。返回 `additionalContexts` 能维持 waterfall 结果的最终权威性,同时复用准入后的 outbox 路径。 + +## 验收标准 + +- `SendOptions` 与 steering 收件箱记录不再包含附加上下文;`agent/queued` 只报告保留的消息和 steering 事实。 +- `AdditionalContext` 在提示词拦截、工具执行、hook bridge、guard 和上下文生产方中取代 `HookContext`,且只包含 `content` 与 `source`。 +- 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。 +- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加并刷新一条带来源的 `user/message`;`whenIdle()` 和 dispose 会等待该刷新。 +- 活跃轮次注入和钩子产生的上下文会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。 +- 被提示词准入阻止的消息不会打开轮次,也不会追加提示词或钩子产生的额外上下文;调用方此前独立注入的上下文仍保留。 +- 单元测试、持久化与 resume 测试、不变量测试、ACP/TUI 回放测试,以及无需密钥的组装应用快照覆盖新的事件顺序和持久性语义。 + +## 风险 + +- 允许一个表层事件位于轮次之外,会削弱一条简单不变量,并可能暴露持久化扫描、崩溃恢复、fork、压缩和会话查询中的隐含假设。 +- 两条连续的 user-role 消息会取代一条烘焙后的提示词消息;提供方适配器和缓存行为必须接受并保留这一顺序。 +- 如果调用方不能接受独立提交契约,`inject()` 后跟一个被阻止的 `send()` 会留下缺少预期直接提示词的上下文。 +- 同步注入 API 无法返回刷新失败。只记录日志的结构化程度低于 `agent/error`,但仅为此场景增加新的持久化事件也可能产生另一个不必要的 seam。 +- 移除附件、放置方式、元数据、封套和一种持久事件类型,是一次影响面较广的预发布迁移,必须原子更新所有生产方和消费方。 From 6f5321cb37d7f456381533c15c3ec1ba2046e8ef Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 16:09:58 +0800 Subject: [PATCH 33/37] feat: workspace select menu --- ...ew-session-clears-to-empty-state.i18n.yaml | 4 +- ...07-24-new-session-clears-to-empty-state.md | 2 +- ...24-new-session-clears-to-empty-state.zh.md | 2 +- .../runtime/src/client/sessions/service.ts | 23 +- .../runtime/tests/sessions-service.spec.ts | 21 ++ .../ui-conversation/src/client/apply.ts | 4 + .../src/client/contract/slots.ts | 5 + .../src/client/skeleton/EmptyState.module.css | 87 ++++-- .../src/client/skeleton/EmptyState.tsx | 256 +++++++++++++----- .../src/client/skeleton/InputBar.module.css | 58 ++-- .../src/client/skeleton/InputBar.tsx | 10 +- .../tests/apply-inject.spec.tsx | 9 +- .../tests/skeleton-branches.spec.tsx | 38 ++- .../ui-conversation/tests/skeleton.spec.tsx | 76 +++++- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/package.json | 2 +- .../ui-primitives/src/Button.module.css | 14 + packages/client/ui-primitives/src/Button.tsx | 2 +- .../client/ui-primitives/src/Menu.module.css | 81 +++++- packages/client/ui-primitives/src/Menu.tsx | 109 ++++++-- .../client/ui-primitives/src/Modal.module.css | 79 ++++++ packages/client/ui-primitives/src/Modal.tsx | 62 +++++ packages/client/ui-primitives/src/index.ts | 5 +- .../client/ui-primitives/tests/atoms.spec.tsx | 87 +++++- packages/host/runtime/src/api-proxy.ts | 14 +- .../host/runtime/tests/host-runtime.spec.ts | 25 +- 26 files changed, 890 insertions(+), 187 deletions(-) create mode 100644 packages/client/ui-primitives/src/Modal.module.css create mode 100644 packages/client/ui-primitives/src/Modal.tsx diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml index 5f41ac4b70..4b7354a322 100644 --- a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-new-session-clears-to-empty-state.md: d730f3e0b658ea66b6026593f37a97893e32a4db -2026-07-24-new-session-clears-to-empty-state.zh.md: 602a2b774b569cfef0adcc253fe751f86b14f895 +2026-07-24-new-session-clears-to-empty-state.md: 1605f44a05d0f59b61fe95cb5b03a0f9f5c3d4ab +2026-07-24-new-session-clears-to-empty-state.zh.md: 1f78d99babc33d30ee1300bfa6bf048a78e7132e diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md index d730f3e0b6..1605f44a05 100644 --- a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md @@ -20,4 +20,4 @@ Sidebar "New Session" created and opened a blank session immediately, so the cen ## Consequences -New Session no longer mints a host session until the first send. Reloading after clear stays on the empty state. Project-scoped "+" still creates immediately. `EmptyState` stacks the Figma hero as fish + title, a Menu-backed workspace chip ("New Workspace" / basename / free-form path) above the card, then shared `InputBar` (`variant="hero"`), with a soft ellipse glow (figma 313:14109) centered behind the picker + card and width-locked to the card (`1051/776`) so it scales with it. `InputBar` paints the bottom chrome (attach / Plan / Read-only / model) with local native `` state only — host plan, access, and model seams remain unwired. diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md index 602a2b774b..1f78d99bab 100644 --- a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md @@ -20,4 +20,4 @@ Status: implemented ## Consequences -New Session 在首次发送前不再创建 host 会话。clear 后重新加载仍停留在空态。项目范围的「+」仍立即创建。`EmptyState` 按 Figma 堆叠英雄区:鱼标 + 标题、卡片上方的 Menu 工作区 chip(「New Workspace」/ 路径 basename / 自由输入路径),再接共用的 `InputBar`(`variant="hero"`);选择器与卡片背后居中铺一层柔光椭圆(figma 313:14109),宽度按卡片锁定为 `1051/776`,随卡片缩放。`InputBar` 绘制底栏 chrome(添加 / Plan / Read-only / 模型),仅用本地原生 `` 状态——host 侧的 plan、access、model 接缝仍未接线。 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 324c574474..d8a6f05762 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -117,7 +117,7 @@ export class SessionsService { * @param ctx - client root context (scope fibers mount under it). * @param api - wire client shared with every Session. */ - constructor(private readonly rootCtx: Context, api: IApiClient) { + constructor(private readonly rootCtx: Context, private readonly api: IApiClient) { this.manager = new SessionManager(api) this.selection = createSnapshotStore<{ sessionId?: SessionId }>( {}, @@ -171,6 +171,27 @@ export class SessionsService { return result.value.sessionId } + /** + * Create a workspace folder under the host process cwd and a session in it. + * Name is a single path segment (no separators); the host mkdir runs inside + * session.create. Caller opens the returned id when it wants the session staged. + * @param name - workspace folder basename. + * @returns the new session id. + */ + async createWorkspace(name: string): Promise { + const trimmed = name.trim() + if (trimmed === '') throw new Error('sessions.createWorkspace: name is required') + if (/[/\\]/.test(trimmed)) { + throw new Error('sessions.createWorkspace: name must not contain path separators') + } + const { result } = await this.api.host.describe({}) + if (!result.ok) { + throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`) + } + const hostCwd = result.value.cwd.replace(/[/\\]+$/, '') + return this.create({ cwd: `${hostCwd}/${trimmed}` }) + } + /** * Resolve a session-scoped context view (use-and-discard). * @param id - session id. diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 33cfce43bb..8c850426bd 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -297,6 +297,27 @@ describe('create', () => { }) }) +describe('createWorkspace', () => { + it('joins host.describe cwd with the name and creates there', async () => { + const b = bench() + b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 })) + b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') })) + await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws') + expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }]) + }) + + it('rejects empty names and path separators; surfaces describe failures', async () => { + const b = bench() + await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/) + await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/) + b.api.onDescribe = () => Promise.resolve({ + rpcId: 'e' as never, + result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } }, + } as never) + await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/) + }) +}) + describe('coverage tails (branch duals)', () => { it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => { const b = bench() diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 8c4a6dc6a1..372eb36c80 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -160,6 +160,10 @@ export function apply(ctx: Context): void { if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable') return conversation.startSession(opts) }, + createWorkspaceSession: async (name) => { + const id = await sessions.createWorkspace(name) + sessions.open(id) + }, }), }, EmptyState) } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index baa26683ec..ffbc13ff59 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -164,6 +164,11 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & export interface EmptyStateInjected { /** The create → navigate → first-send chain, in one service call. */ startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise + /** + * Create a workspace folder under the host cwd, mint a session there, and + * open it (Create-new modal success path). + */ + createWorkspaceSession(name: string): Promise } /** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css index 53e618b122..bc5d9d62d0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css @@ -1,6 +1,6 @@ -/* NEW SESSION hero (figma 313:14149): fish + title, workspace chip above the - shared InputBar card. The input itself is InputBar — only stack geometry - lives here. */ +/* NEW SESSION hero (figma Input_Bottom 75:8208): fish + title, workspace chip + above the shared InputBar card. The input itself is InputBar — only stack + geometry and the chip live here. */ .root { display: flex; @@ -11,23 +11,26 @@ padding: 24px; } -/* Cap matches InputBar card width (776). Glow may paint past the sides. */ +/* Cap matches InputBar card width (800). Glow may paint past the sides. */ .stack { display: flex; flex-direction: column; align-items: stretch; - gap: 40px; + /* figma 75:8208: 12 between title block / workspace / card. */ + gap: 12px; width: 100%; - max-width: 776px; + max-width: 800px; overflow: visible; } -/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600. */ +/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block + keeps 36px below the headline before the flex gap. */ .headline { display: flex; align-items: center; justify-content: center; gap: 10px; + padding-bottom: 36px; font-size: 26px; line-height: 32px; font-weight: 600; @@ -68,38 +71,43 @@ z-index: 1; } -.workspaceRow { +/* Must beat `.body > :not(.glow)` specificity so the open Menu (and its + right-hand submenu) paints above the InputBar card. */ +.body > .workspaceRow { + z-index: 10; display: flex; align-items: center; min-width: 0; - /* Align with InputBar's left chrome (card pad 10 + attach). */ - padding-left: 10px; + /* figma 75:8208 workspace row: px 8 above the card. */ + padding-left: 8px; } -/* Folder + "New Workspace" + chevron (figma workspace trigger). */ +/* Folder + label + chevron — transparent at rest; fill only on hover / open. */ .workspace { display: inline-flex; align-items: center; - gap: 6px; + gap: 4px; max-width: 100%; - height: 28px; - padding: 0 4px 0 0; + min-height: 28px; + padding: 0 8px; border: none; - border-radius: 8px; + border-radius: 12px; background: transparent; color: var(--dsw-alias-label-primary); - font-size: 14px; + font-size: 13px; line-height: 20px; + font-weight: 500; cursor: pointer; } -.workspace:hover { +.workspace:hover, +.workspace[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); } .folder { flex: none; - color: var(--dsw-alias-label-tertiary); + color: var(--dsw-alias-label-primary); } .workspaceLabel { @@ -113,19 +121,44 @@ color: var(--dsw-alias-label-caption); } -.customInput { - width: min(320px, 100%); - height: 28px; - padding: 0 10px; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 8px; +/* Workspace menu width tracks the longest basename in the Figma frame. */ +.workspaceMenu :global([role='menu']) { + min-width: 240px; +} + +/* Dialog field (figma 451:18655 Input): h44, r22, px 14, caption placeholder. */ +.modalInput { + width: 100%; + height: 44px; + padding: 0 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 22px; outline: none; - background: var(--dsw-alias-bg-base); + background: transparent; font-size: 14px; - line-height: 20px; + line-height: 24px; color: var(--dsw-alias-label-primary); } -.customInput:focus { +.modalInput::placeholder { + color: var(--dsw-alias-label-caption); +} + +.modalInput:focus { border-color: var(--dsw-alias-state-business-primary); } + +.modalInput:disabled { + color: var(--dsw-alias-label-dimmed); +} + +.modalAction { + min-width: 72px; +} + +.modalError { + margin-top: 8px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); +} diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx index edcf0cbad2..b112dfa432 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -1,16 +1,21 @@ // EmptyState (figma NEW SESSION screen): centered hero — fish + title, -// workspace picker row, then the SAME InputBar the resident composer uses -// (empty→content is a position move, never a swap). Project picker: cwd set -// derived in-component from useSessions plus a free-form new-directory path; -// submit runs startSession (create → open → send). +// workspace picker row (MenuDropdown 122:9481 + New Workspace submenu +// 419:16920 + Dialog 451:18655), then the SAME InputBar the resident +// composer uses (empty→content is a position move, never a swap). Project +// options derive in-component from useSessions; Create new runs +// createWorkspaceSession (host mkdir + session.create + open). import { useId, useMemo, useState } from 'react' import { + Button, FishLogo, IconChevronDownOutline14, + IconFolderClose16, IconFolderOpen16, + IconPlusOutline16, Menu, - type MenuItem, + Modal, + type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client' import type { EmptyStateSlotProps } from '../contract/slots.ts' @@ -18,10 +23,15 @@ import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' import css from './EmptyState.module.css' -/** Menu id for the free-form directory entry (not a filesystem path). */ -const NEW_DIR = '::new-directory' -/** Menu id for the host default project directory (empty cwd on create). */ -const DEFAULT_DIR = '::default' +/** Menu id for "New Workspace" (opens submenu; not a cwd). */ +const NEW_WORKSPACE = '::new-workspace' +/** Submenu: path modal (figma 451:18655 copy). */ +const USE_EXISTING = '::use-existing' +/** Submenu: create-workspace modal → mkdir + default session. */ +const CREATE_NEW = '::create-new' + +/** Which full-page dialog is open (null = none). */ +type ModalKind = 'path' | 'create' | null /** Full props composed by reference from the contract (runtime share & injected share; no store). */ export type EmptyStateProps = EmptyStateSlotProps @@ -36,22 +46,26 @@ function deriveCwds(state: SessionListState): readonly string[] { return [...seen] } -/** Basename for the workspace chip; empty → the design's "New Workspace" label. */ +/** Basename for the workspace chip / menu row; empty → the design's "New Workspace" label. */ function workspaceLabel(cwd: string): string { if (cwd === '') return 'New Workspace' const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() return base !== undefined && base !== '' ? base : cwd } -export function EmptyState({ useSessions, startSession }: EmptyStateProps) { +export function EmptyState({ useSessions, startSession, createWorkspaceSession }: EmptyStateProps) { const list = useSessions(s => s) const cwds = useMemo(() => deriveCwds(list), [list]) // Local viewing state: the empty state owns no session, so its draft is // ephemeral by design (drafts are keyed by session id; there is none yet). const [draft, setDraft] = useState('') const [cwd, setCwd] = useState('') - const [custom, setCustom] = useState(false) const [menuOpen, setMenuOpen] = useState(false) + const [modalKind, setModalKind] = useState(null) + const [pathDraft, setPathDraft] = useState('') + const [workspaceName, setWorkspaceName] = useState('New WorkSpace') + const [creating, setCreating] = useState(false) + const [modalError, setModalError] = useState(null) const [sending, setSending] = useState(false) const [error, setError] = useState(null) // Stable filter id so multiple EmptyState mounts do not collide in the DOM. @@ -74,59 +88,64 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { // Success needs no cleanup: the session selection swaps this slot out for the session body. } - const items: MenuItem[] = [ - { id: DEFAULT_DIR, label: 'Default directory' }, - ...cwds.map(c => ({ id: c, label: c })), - { id: NEW_DIR, label: 'New directory…' }, + const items: MenuEntry[] = [ + ...cwds.map(c => ({ + id: c, + label: workspaceLabel(c), + icon: , + })), + ...(cwds.length > 0 ? [{ type: 'separator' as const, id: 'sep-new' }] : []), + { + id: NEW_WORKSPACE, + label: 'New Workspace', + icon: , + submenu: [ + { id: USE_EXISTING, label: 'Use a existing folder' }, + { id: CREATE_NEW, label: 'Create new' }, + ], + }, ] - const selectedId = custom ? NEW_DIR : cwd === '' ? DEFAULT_DIR : cwd - const workspace = custom - ? ( - { setCwd(e.target.value) }} - /> - ) - : ( - { setMenuOpen(false) }} - selectedId={selectedId} - items={items} - onSelect={(id) => { - if (id === NEW_DIR) { - setCustom(true) - setCwd('') - } else if (id === DEFAULT_DIR) { - setCustom(false) - setCwd('') - } else { - setCustom(false) - setCwd(id) - } - setMenuOpen(false) - }} - anchor={( - - )} - /> - ) + const closeModal = (): void => { + if (creating) return + setModalKind(null) + setModalError(null) + } + + const openPathModal = (): void => { + setPathDraft(cwd) + setModalError(null) + setModalKind('path') + } + + const openCreateModal = (): void => { + setWorkspaceName('New WorkSpace') + setModalError(null) + setModalKind('create') + } + + const confirmPath = (): void => { + const next = pathDraft.trim() + if (next === '') return + setCwd(next) + setModalKind(null) + } + + const confirmCreate = (): void => { + if (creating) return + setCreating(true) + setModalError(null) + createWorkspaceSession(workspaceName) + .catch((reason: unknown) => { + setModalError(reason instanceof Error ? reason.message : String(reason)) + setCreating(false) + }) + // Success swaps this slot out for the new session body — no local cleanup. + } + + const modalBusy = creating + const isPath = modalKind === 'path' + const isCreate = modalKind === 'create' return (
@@ -138,7 +157,8 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width - tracks the card (1051/776) so blur scales in userSpace with it. */} + tracks the card (glow asset 1051 vs design card 776) so blur + scales in userSpace with it. */} -
{workspace}
+
+ { setMenuOpen(false) }} + {...(cwd !== '' ? { selectedId: cwd } : {})} + items={items} + side="top" + className={css.workspaceMenu!} + onSelect={(id) => { + if (id === USE_EXISTING) { + setMenuOpen(false) + openPathModal() + return + } + if (id === CREATE_NEW) { + setMenuOpen(false) + openCreateModal() + return + } + setCwd(id) + setMenuOpen(false) + }} + anchor={( + + )} + /> +
+ + + + + )} + > + { setPathDraft(e.target.value) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + confirmPath() + } + }} + /> + + + + + + )} + > + { setWorkspaceName(e.target.value) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + confirmCreate() + } + }} + /> + {modalError !== null &&
{modalError}
} +
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 04e4f661c5..7161a31931 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -1,7 +1,7 @@ -/* Floating capsule input (figma Input_Bottom 34:11445): card floats above the +/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the viewport bottom inside the centered message column; textarea on top, action row below, one primary circle button bottom-right. Input width rides the - column (776 is a cap, not a fixed size — layout rule: the box shrinks with + column (800 is a cap, not a fixed size — layout rule: the box shrinks with the center column keeping its padding). Hero variant = the same card centered in the empty state; the transition between the two is a position move of one component. */ @@ -10,8 +10,8 @@ display: flex; flex-direction: column; align-items: center; - /* figma Input_Bottom 34:11445: pad L32/R32/B12; the bottom gradient mask is - owned by the chat scroller. Top 8 hosts the error strip's breathing room. */ + /* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by + the chat scroller. Top 8 hosts the error strip's breathing room. */ padding: 8px 32px 12px; } @@ -21,7 +21,7 @@ .error { width: 100%; - max-width: 776px; + max-width: 800px; margin-bottom: 6px; padding: 4px 8px; border-radius: 8px; @@ -34,10 +34,12 @@ .card { display: flex; flex-direction: column; - /* figma Input 34:11458: 12px between the text area and the button row. */ + /* figma Input 75:8208: 12px between the text area and the button row; 10px + top pad on the card before .InputText. */ gap: 12px; width: 100%; - max-width: 776px; + max-width: 800px; + padding-top: 10px; /* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says the input border is one notch weaker than buttons) — exactly the l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */ @@ -49,11 +51,6 @@ line-height: 24px; } -/* New-session state rounds up (figma: r24 and a taller box). */ -.hero .card { - border-radius: 24px; -} - .accessory { display: flex; align-items: center; @@ -85,7 +82,8 @@ .input, .mirror { - padding: 12px 16px 0; + /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */ + padding: 4px 12px 0 16px; font-size: inherit; line-height: inherit; white-space: pre-wrap; @@ -108,17 +106,12 @@ .mirror { visibility: hidden; pointer-events: none; - /* 2-line floor: 2 × 24px line + 12px top padding; 14-line cap (336px). */ - min-height: 60px; + /* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */ + min-height: 52px; max-height: 336px; overflow: hidden; } -.hero .mirror { - /* New-session box is taller at rest (figma 118px input area). */ - min-height: 84px; -} - /* Toolbar: attach + Plan + Read-only on the left; model + send on the right (figma Input_Bottom chrome). */ .row { @@ -131,16 +124,25 @@ } .tools, +.modes, .trailing { display: flex; align-items: center; - gap: 4px; min-width: 0; } +/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */ +.tools { + gap: 16px; +} + +.modes { + gap: 4px; +} + .trailing { flex: none; - gap: 8px; + gap: 12px; } /* Attach circle (figma + control): 28px, selector fill, primary glyph. */ @@ -166,22 +168,24 @@ cursor: default; } -/* Plan / Read-only / model — native , chip-like closed chrome + (figma ToggleButton: 13/20 medium secondary, 12px chevron). */ .select { max-width: 220px; height: 28px; - padding: 0 22px 0 6px; + padding: 0 20px 0 8px; border: none; border-radius: 8px; outline: none; background-color: transparent; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14' fill='none'%3E%3Cpath d='M3.5 5.25L7 8.75L10.5 5.25' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 4px center; - background-size: 14px 14px; + background-size: 12px 12px; color: var(--dsw-alias-label-secondary); - font-size: 14px; + font-size: 13px; line-height: 20px; + font-weight: 500; white-space: nowrap; cursor: pointer; appearance: none; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index f6454071b3..04d1dd867d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -175,8 +175,10 @@ export function InputBar({ > - {renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} - {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} +
+ {renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} + {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} +
{renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)} @@ -190,11 +192,11 @@ export function InputBar({ onClick={onPrimary} > {running ? ( - + ) : ( - + )} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 3043c71ed5..edd9f7d54d 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -78,6 +78,7 @@ async function bench() { cell: () => undefined, scopeOf, create: vi.fn(() => Promise.resolve(ROOT)), + createWorkspace: vi.fn(() => Promise.resolve(ROOT)), open: vi.fn(), } ctx.provide('sessions', sessionsFake) @@ -239,16 +240,20 @@ describe('details and empty inject surfaces', () => { expect(details).toBe(conv) }) - it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => { + it('empty injects startSession and createWorkspaceSession (no store, cwds derive in-component)', async () => { const b = await bench() const entry = b.entryOf('conversation.empty') expect(entry.store).toBeUndefined() const injected = (entry.inject as unknown as () => EmptyStateInjected)() - expect(Object.keys(injected)).toEqual(['startSession']) + expect(Object.keys(injected).sort()).toEqual(['createWorkspaceSession', 'startSession']) await injected.startSession({ text: 'go', mode: 'queue' }) expect(b.sessionsFake.create).toHaveBeenCalled() expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue') + b.sessionsFake.open.mockClear() + await injected.createWorkspaceSession('Fresh') + expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh') + expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) }) it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => { diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index b93b9c0b72..d1bd50437f 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -238,10 +238,16 @@ describe('DetailsPanel branches', () => { }) describe('EmptyState branches', () => { + const noopCreate = () => Promise.resolve() + it('keeps the draft and surfaces a local error strip when startSession rejects', async () => { const startSession = vi.fn(() => Promise.reject(new Error('create down'))) const view = render( - , + , ) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'first task' } }) @@ -253,7 +259,11 @@ describe('EmptyState branches', () => { it('non-Error rejection reasons stringify into the error strip', async () => { const startSession = vi.fn(() => Promise.reject('plain-string')) const view = render( - , + , ) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'go' } }) @@ -270,15 +280,17 @@ describe('EmptyState branches', () => { { id: 'b', title: 'b' }, // no cwd: filtered from the option set ])} startSession={startSession} + createWorkspaceSession={noopCreate} />, ) fireEvent.click(view.getByRole('button', { name: '项目目录' })) expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) - .toEqual(['Default directory', '/proj', 'New directory…']) - fireEvent.click(view.getByRole('menuitem', { name: '/proj' })) + .toEqual(['proj', 'New Workspace']) + fireEvent.click(view.getByRole('menuitem', { name: 'proj' })) expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj') fireEvent.click(view.getByRole('button', { name: '项目目录' })) - fireEvent.click(view.getByRole('menuitem', { name: 'New directory…' })) + fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) + fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' })) const custom = view.container.querySelector('input')! fireEvent.change(custom, { target: { value: '/typed/dir' } }) const textarea = view.container.querySelector('textarea')! @@ -286,4 +298,20 @@ describe('EmptyState branches', () => { fireEvent.keyDown(textarea, { key: 'Enter' }) await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' })) }) + + it('Create modal surfaces inject failures inline', async () => { + const createWorkspaceSession = vi.fn(() => Promise.reject(new Error('mkdir blocked'))) + const view = render( + Promise.resolve()} + createWorkspaceSession={createWorkspaceSession} + />, + ) + fireEvent.click(view.getByRole('button', { name: '项目目录' })) + fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) + fireEvent.click(view.getByRole('menuitem', { name: 'Create new' })) + fireEvent.click(view.getByRole('button', { name: 'Create' })) + await waitFor(() => expect(view.getByRole('alert').textContent).toContain('mkdir blocked')) + }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b2636bfd4b..a598803a25 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -86,6 +86,8 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId? const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))} describe('EmptyState', () => { + const noopCreate = () => Promise.resolve() + it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => { const { useSessions } = fakeSessions([ { id: 'a', title: 'a', cwd: '/w/app' }, @@ -94,14 +96,20 @@ describe('EmptyState', () => { ]) let reject!: (e: Error) => void const startSession = vi.fn(() => new Promise((_res, rej) => { reject = rej })) - render() + render( + , + ) const trigger = screen.getByRole('button', { name: '项目目录' }) fireEvent.click(trigger) const menu = screen.getByRole('menu') expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) - .toEqual(['Default directory', '/w/app', '/w/lib', 'New directory…']) - fireEvent.click(screen.getByRole('menuitem', { name: '/w/app' })) + .toEqual(['app', 'lib', 'New Workspace']) + fireEvent.click(screen.getByRole('menuitem', { name: 'app' })) const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands') fireEvent.change(box, { target: { value: '造一个轮子' } }) fireEvent.keyDown(box, { key: 'Enter' }) @@ -113,14 +121,64 @@ describe('EmptyState', () => { expect((box as HTMLTextAreaElement).value).toBe('造一个轮子') }) - it('new-directory option swaps the chip for a free-form input', () => { + it('Use a existing folder opens the path modal and Open Folder sets the chip', () => { const { useSessions } = fakeSessions([]) - render( Promise.resolve()} />) + render( + Promise.resolve()} + createWorkspaceSession={noopCreate} + />, + ) fireEvent.click(screen.getByRole('button', { name: '项目目录' })) - fireEvent.click(screen.getByRole('menuitem', { name: 'New directory…' })) - const custom = screen.getByPlaceholderText(/Directory path/) - fireEvent.change(custom, { target: { value: '/tmp/fresh' } }) - expect((custom as HTMLInputElement).value).toBe('/tmp/fresh') + const newWs = screen.getByRole('menuitem', { name: 'New Workspace' }) + fireEvent.mouseEnter(newWs.parentElement as HTMLElement) + fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' })) + expect(screen.getByRole('dialog', { name: 'Enter an existing folder path' })).toBeTruthy() + const path = screen.getByLabelText('Folder path') as HTMLInputElement + fireEvent.change(path, { target: { value: '/tmp/fresh' } }) + fireEvent.click(screen.getByRole('button', { name: 'Open Folder' })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByRole('button', { name: '项目目录' }).textContent).toContain('fresh') + }) + + it('Create new opens the modal and createWorkspaceSession succeeds', async () => { + const { useSessions } = fakeSessions([]) + const createWorkspaceSession = vi.fn(() => Promise.resolve()) + render( + Promise.resolve()} + createWorkspaceSession={createWorkspaceSession} + />, + ) + fireEvent.click(screen.getByRole('button', { name: '项目目录' })) + fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) + fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' })) + expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeTruthy() + const name = screen.getByLabelText('Workspace name') as HTMLInputElement + expect(name.value).toBe('New WorkSpace') + fireEvent.change(name, { target: { value: 'My Proj' } }) + fireEvent.keyDown(name, { key: 'Enter' }) + await vi.waitFor(() => expect(createWorkspaceSession).toHaveBeenCalledWith('My Proj')) + }) + + it('Create modal Cancel dismisses without calling createWorkspaceSession', () => { + const { useSessions } = fakeSessions([]) + const createWorkspaceSession = vi.fn(() => Promise.resolve()) + render( + Promise.resolve()} + createWorkspaceSession={createWorkspaceSession} + />, + ) + fireEvent.click(screen.getByRole('button', { name: '项目目录' })) + fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) + fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' })) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(createWorkspaceSession).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index da6382be4b..5b158c453a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-primitives -Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8. +Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8. ## Markdown rendering diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index fda27fdd4d..44c35eb517 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", - "description": "Pure React atoms for the dsh web UI: StateDot, ic_ds_* icon set, Button/Pill/Menu/Input, markdown family (zero cordis)", + "description": "Pure React atoms for the dsh web UI: StateDot, ic_ds_* icon set, Button/Pill/Menu/Modal/Input, markdown family (zero cordis)", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css index 1cb3b18194..3f415b5d15 100644 --- a/packages/client/ui-primitives/src/Button.module.css +++ b/packages/client/ui-primitives/src/Button.module.css @@ -56,6 +56,20 @@ background: var(--dsw-alias-interactive-bg-active); } +/* Dialog Cancel (figma 451:18655): bordered capsule on transparent fill. */ +.outline { + border: 1px solid var(--dsw-alias-border-l2); + background: transparent; +} + +.outline:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.outline:disabled { + border-color: var(--dsw-alias-border-l1); +} + .toolbar { background: var(--dsw-alias-button-tool-bar-fill); } diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx index 028c1fc266..642372868a 100644 --- a/packages/client/ui-primitives/src/Button.tsx +++ b/packages/client/ui-primitives/src/Button.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' import css from './Button.module.css' /** Visual variant, each backed by its --dsw-alias-button-* token family. */ -export type ButtonVariant = 'primary' | 'ghost' | 'toolbar' +export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar' /** * Render a button. diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 3e3bf85299..3cbcb62d29 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -3,21 +3,32 @@ display: inline-flex; } -/* Dropdown card (figma MenuDropdown 122:10096): white card, r12, no border, - * layered drop shadows via the shadow token, 4px inset padding. */ +/* Dropdown card (figma MenuDropdown 122:9481 / 419:16920): menu surface, + * r12, inverted hairline border, shadow-lv3, 4px inset padding. */ +.list, +.submenu { + padding: 4px; + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 12px; + background: var(--dsw-specific-menu); + box-shadow: var(--dsw-shadow-lv3); +} + .list { position: absolute; top: calc(100% + 4px); left: 0; z-index: 100; min-width: 130px; - padding: 4px; - display: flex; - flex-direction: column; - gap: 0; - border-radius: 12px; - background: var(--dsw-alias-bg-layer-1); - box-shadow: var(--dsw-shadow-lv2); +} + +/* Open above the anchor (empty-state workspace chip: figma 122:9481). */ +.sideTop { + top: auto; + bottom: calc(100% + 4px); } .alignEnd { @@ -25,12 +36,18 @@ right: 0; } -/* Menu cell (figma .Menu_cell 27:5169): r10, pad 10/8, 14/22 primary text, +.itemWrap { + position: relative; +} + +/* Menu cell (figma .Menu_cell): min-h 40, r10, pad 10/8, 14/22 primary, * gap 8 between leading icon / label / trailing check. */ .item { display: flex; align-items: center; gap: 8px; + width: 100%; + min-height: 40px; padding: 8px 10px; border: none; border-radius: 10px; @@ -51,9 +68,22 @@ cursor: not-allowed; } +.itemIcon { + display: inline-flex; + flex: none; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + color: var(--dsw-alias-label-tertiary); +} + .itemLabel { flex: 1; min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .check { @@ -66,3 +96,34 @@ .selected { background: transparent; } + +/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */ +.separator { + height: 1px; + margin: 4px 2px; + background: var(--dsw-alias-border-l1); +} + +/* Nested card to the right of the parent row (figma 419:16920). + * Bottom-aligned with the parent menu card (grows upward): itemWrap sits in + * .list's 4px pad, so bottom: -4px matches the list's outer bottom edge. + * Horizontal: list pad (4px) + 6px card gap = 10px past itemWrap — plain + * `100% + 6px` collapses to ~2px between outer card edges. + * ::before bridges the full gap so the pointer can cross without mouseLeave. */ +.submenu { + position: absolute; + top: auto; + bottom: -4px; + left: calc(100% + 10px); + z-index: 101; + min-width: 160px; +} + +.submenu::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -10px; + width: 10px; +} diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ad45acc221..0b07c26357 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -1,46 +1,69 @@ // Menu: minimal controlled dropdown (group-by pickers, project selectors). // Pure CSS positioning relative to the anchor wrapper — no portal, no popper. // The owner controls `open`; outside-click closing uses one document listener -// active only while open. +// active only while open. Submenus open on hover/focus inside the same root. -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' import { IconCheckOutline16 } from './icons/index.tsx' import css from './Menu.module.css' -/** One selectable menu row. */ +/** Selectable row (optionally with a nested submenu). */ export interface MenuItem { id: string label: ReactNode disabled?: boolean + /** Leading icon (figma .Menu_cell gap 8). */ + icon?: ReactNode + /** Nested card opened to the right on hover/focus. */ + submenu?: readonly MenuItem[] +} + +/** Hairline between item groups (not selectable). */ +export interface MenuSeparator { + type: 'separator' + id: string +} + +/** One primary-menu entry: a row or a separator. */ +export type MenuEntry = MenuItem | MenuSeparator + +function isSeparator(entry: MenuEntry): entry is MenuSeparator { + return 'type' in entry && entry.type === 'separator' } /** * Render an anchored dropdown menu. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). - * @param props.items - selectable rows. + * @param props.items - selectable rows and optional separators. * @param props.selectedId - row shown as selected. - * @param props.onSelect - row click callback (not called for disabled rows). + * @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children). * @param props.onClose - invoked on outside click or Escape. * @param props.align - list alignment against the anchor (default 'start'). + * @param props.side - open below (`bottom`, default) or above (`top`) the anchor. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', className }: { +export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', className }: { open: boolean anchor: ReactNode - items: readonly MenuItem[] + items: readonly MenuEntry[] selectedId?: string onSelect: (id: string) => void onClose: () => void align?: 'start' | 'end' + side?: 'bottom' | 'top' className?: string }) { const rootRef = useRef(null) + const [openSubmenuId, setOpenSubmenuId] = useState(null) useEffect(() => { - if (!open) return + if (!open) { + setOpenSubmenuId(null) + return + } const onPointerDown = (e: PointerEvent) => { if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) onClose() } @@ -59,21 +82,61 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align {anchor} {open && ( -
- {items.map(item => ( - - ))} +
+ {items.map(entry => { + if (isSeparator(entry)) { + return
+ } + const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 + const subOpen = hasSub && openSubmenuId === entry.id + return ( +
{ setOpenSubmenuId(hasSub ? entry.id : null) }} + onMouseLeave={() => { setOpenSubmenuId(null) }} + > + + {subOpen && entry.submenu !== undefined && ( +
+ {entry.submenu.map(sub => ( + + ))} +
+ )} +
+ ) + })}
)} diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css new file mode 100644 index 0000000000..49026f7a5f --- /dev/null +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -0,0 +1,79 @@ +/* Full-viewport layer (figma Mask + Dialog 451:18655): mask + centered card. */ +.root { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +/* User/spec mask: rgba(0,0,0,0.24) + blur(2px) via --dsw-alias-bg-mask-1 / + --dsw-mask-blur (light); dark theme raises mask opacity. */ +.mask { + position: absolute; + inset: 0; + background: var(--dsw-alias-bg-mask-1); + backdrop-filter: var(--dsw-mask-blur); +} + +/* Dialog card: r24, shadow-lv3, layer-2 fill, inverted border, pb 24. */ +.dialog { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 20px; + width: min(380px, 100%); + padding: 0 0 24px; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 24px; + background: var(--dsw-alias-bg-layer-2); + box-shadow: var(--dsw-shadow-lv3); +} + +.content { + display: flex; + flex-direction: column; + width: 100%; +} + +/* Header pad (figma Title row): pt 22 / pl 24 / pr 14 / pb 12. */ +.header { + display: flex; + flex-direction: column; + gap: 8px; + padding: 22px 14px 12px 24px; +} + +.title { + margin: 0; + font-size: 16px; + line-height: 24px; + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +.description { + margin: 0; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-secondary); +} + +.body { + display: flex; + flex-direction: column; + min-width: 0; + padding: 0 24px; +} + +.footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 0 24px; +} diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx new file mode 100644 index 0000000000..cdbe1060bf --- /dev/null +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -0,0 +1,62 @@ +// Modal: controlled full-viewport dialog (create-workspace and similar). +// Fixed overlay in the React tree (no react-dom portal) so ui-primitives +// stays free of a react-dom dependency; mask tokens match figma 451:18655. + +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import clsx from 'clsx' +import css from './Modal.module.css' + +/** + * Render a centered modal over a blurred page mask. + * @param props.open - whether the dialog is showing. + * @param props.onClose - Escape or mask click. + * @param props.title - dialog heading. + * @param props.description - optional supporting sentence under the title. + * @param props.children - body (inputs, etc.). + * @param props.footer - action row (Cancel / Create). + * @returns null when closed; otherwise the overlay tree. + */ +export function Modal({ open, onClose, title, description, children, footer, className }: { + open: boolean + onClose: () => void + title: string + description?: string + children?: ReactNode + footer?: ReactNode + className?: string +}) { + useEffect(() => { + if (!open) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKeyDown) + return () => { document.removeEventListener('keydown', onKeyDown) } + }, [open, onClose]) + + if (!open) return null + + return ( +
+ + ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index e5e2e4e88f..0d6cde4ed0 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -1,5 +1,5 @@ /** - * Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Input, + * Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Modal/Input, * markdown family, ConnectionBanner. Everything consumes props plus --dsw-* * token vars only. Contract: api-contracts v3 section 8. */ @@ -11,7 +11,8 @@ export type { ButtonVariant } from './Button.tsx' export { Pill } from './Pill.tsx' export { Input } from './Input.tsx' export { Menu } from './Menu.tsx' -export type { MenuItem } from './Menu.tsx' +export type { MenuEntry, MenuItem, MenuSeparator } from './Menu.tsx' +export { Modal } from './Modal.tsx' export { ConnectionBanner } from './ConnectionBanner.tsx' export { FishLogo } from './FishLogo.tsx' export { BrandWordmark } from './BrandWordmark.tsx' diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index f259cb334a..a4b286ced7 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Button, ConnectionBanner, Input, Menu, Pill } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) @@ -21,6 +21,11 @@ describe('Button', () => { fireEvent.click(screen.getByRole('button')) expect(onClick).not.toHaveBeenCalled() }) + + it('outline variant renders a bordered cancel-style button', () => { + render() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined() + }) }) describe('Pill', () => { @@ -91,11 +96,12 @@ describe('Menu', () => { expect(onClose).not.toHaveBeenCalled() }) - it('selected item shows the trailing check; align=end and className apply', () => { + it('selected item shows the trailing check; align=end, side=top, and className apply', () => { const { container } = render( trigger} items={items} @@ -104,12 +110,89 @@ describe('Menu', () => { onClose={() => {}} />) expect((container.firstElementChild as HTMLElement).classList.contains('x')).toBe(true) + const menu = screen.getByRole('menu') + expect(menu.className).toMatch(/sideTop|alignEnd/) const selected = screen.getByRole('menuitem', { name: 'Alpha' }) expect(selected.querySelector('svg')).not.toBeNull() const other = screen.getByRole('menuitem', { name: 'Beta' }) expect(other.querySelector('svg')).toBeNull() fireEvent.keyDown(document, { key: 'a' }) }) + + it('renders a leading icon and a separator between groups', () => { + render( + trigger} + items={[ + { id: 'a', label: 'Alpha', icon: }, + { type: 'separator', id: 's1' }, + { id: 'c', label: 'Create' }, + ]} + onSelect={() => {}} + onClose={() => {}} + />) + expect(screen.getByTestId('ic')).toBeDefined() + expect(screen.getByRole('separator')).toBeDefined() + }) + + it('opens a submenu on hover and selects a nested item', () => { + const onSelect = vi.fn() + render( + trigger} + items={[ + { id: 'plain', label: 'Plain' }, + { + id: 'new', + label: 'New Workspace', + submenu: [ + { id: 'ok', label: 'Create ok', icon: }, + ], + }, + ]} + onSelect={onSelect} + onClose={() => {}} + />) + const plain = screen.getByRole('menuitem', { name: 'Plain' }) + fireEvent.mouseEnter(plain.parentElement as HTMLElement) + fireEvent.focus(plain) + const parent = screen.getByRole('menuitem', { name: 'New Workspace' }) + const wrap = parent.parentElement as HTMLElement + fireEvent.click(parent) + expect(onSelect).not.toHaveBeenCalled() + fireEvent.focus(parent) + fireEvent.mouseEnter(wrap) + expect(screen.getByTestId('sub-ic')).toBeDefined() + fireEvent.click(screen.getByRole('menuitem', { name: 'Create ok' })) + expect(onSelect).toHaveBeenCalledWith('ok') + fireEvent.mouseLeave(wrap) + expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull() + }) +}) + +describe('Modal', () => { + it('is absent while closed; Escape and mask click call onClose', () => { + const onClose = vi.fn() + const { rerender } = render( + body) + expect(screen.queryByRole('dialog')).toBeNull() + rerender( + Create}> + + ) + expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined() + expect(screen.getByText('Name it.')).toBeDefined() + fireEvent.keyDown(document, { key: 'a' }) + expect(onClose).not.toHaveBeenCalled() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + // Mask is the presentation sibling behind the dialog. + const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement + fireEvent.click(mask) + expect(onClose).toHaveBeenCalledTimes(2) + }) }) describe('ConnectionBanner', () => { diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 2d674912cd..3792d17da3 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -4,7 +4,7 @@ */ import { randomUUID } from 'node:crypto' -import { stat } from 'node:fs/promises' +import { mkdir, stat } from 'node:fs/promises' import type { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' @@ -407,8 +407,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const sessionId = `session-${randomUUID()}` as SessionId // A session's cwd is its project path. When the creator does not choose // one, the default project is the host-level default (the host process - // working directory unless boot overrides it). + // working directory unless boot overrides it). Ensure the directory + // exists so Create-workspace and typed paths land on a real folder. const cwd = request.payload.cwd ?? defaults.cwd + try { + await mkdir(cwd, { recursive: true }) + } catch (error: unknown) { + return err(request, { + code: 'internal', + message: `failed to ensure project directory "${cwd}": ${String(error)}`, + details: {}, + }) + } const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } }) return ok(request, { sessionId: handle.agent.id }) }, diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index c30e07b50b..4058fdfe2e 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -231,6 +231,29 @@ describe('sessions.create / list', () => { expect(first?.running).toBe(false) expect(first?.parentSessionId).toBeUndefined() }) + + it('ensures a missing project directory before minting the session', async () => { + const { api } = await boot() + const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-')) + const cwd = join(root, 'nested', 'workspace') + expect(existsSync(cwd)).toBe(false) + const { sessionId } = expectOk(await api.sessions.create(request({ cwd }))) + expect(existsSync(cwd)).toBe(true) + const { items } = expectOk(await api.sessions.list(request({}))) + expect(items.find(item => item.sessionId === sessionId)?.cwd).toBe(cwd) + }) + + it('fails loud when the project directory cannot be created', async () => { + const { api } = await boot() + const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-fail-')) + const blocker = join(root, 'file-not-dir') + writeFileSync(blocker, 'x') + const response = await api.sessions.create(request({ cwd: join(blocker, 'child') })) + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('expected mkdir failure') + expect(response.result.error.code).toBe('internal') + expect(response.result.error.message).toMatch(/failed to ensure project directory/) + }) }) describe('sessions.prompt / cancel', () => { From 6fc92bbb163e7361dc91014c83593c271c960f39 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 16:32:35 +0800 Subject: [PATCH 34/37] fix: ci --- .../ui-conversation/tests/skeleton-branches.spec.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index d1bd50437f..4eb70ea39f 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -3,7 +3,7 @@ // acceptance flows), four-share props form: breadcrumb ancestry derivation + // error strip in ConversationRoot, DetailsPanel non-JSON args / non-text // result blocks / error-only results over the shared store, EmptyState -// failure surface and custom-directory swap with in-component cwd derivation. +// failure surface and path-modal confirm with in-component cwd derivation. import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' @@ -271,7 +271,7 @@ describe('EmptyState branches', () => { await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy()) }) - it('cwd derivation skips blank cwds; menu picks, swaps to free-form, submits the typed path', async () => { + it('cwd derivation skips blank cwds; menu picks, path modal confirms, submits the typed path', async () => { const startSession = vi.fn(() => Promise.resolve()) const view = render( { fireEvent.click(view.getByRole('button', { name: '项目目录' })) fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' })) - const custom = view.container.querySelector('input')! + const custom = view.getByLabelText('Folder path') fireEvent.change(custom, { target: { value: '/typed/dir' } }) + fireEvent.click(view.getByRole('button', { name: 'Open Folder' })) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'task' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) From 4be3301952bff43b50838f2a91632b5bc8c6e79b Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 16:56:36 +0800 Subject: [PATCH 35/37] fix: migrate merged-in context/message references to user/message The master merge introduced a tui goal-restore test and the guard parent README that still used the removed context/message event. Point both at the coalesced plugin-sourced user/message. --- packages/guard/README.md | 2 +- packages/ui/tui/tests/tui.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/guard/README.md b/packages/guard/README.md index 05c9625cb0..e7066ba434 100644 --- a/packages/guard/README.md +++ b/packages/guard/README.md @@ -6,4 +6,4 @@ Behavioral guard plugins that watch the agent loop for unproductive patterns and |---|---|---| | `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) | -Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged `context/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log. +Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log. diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index fad0a63b1b..03afab380f 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1073,7 +1073,7 @@ describe('pi-tui chat lifecycle and transcript', () => { } const result = await setup({ beforeMount(session) { - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: { kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0 }, meta: change as unknown as JsonValue, From 51f697db50e99eacaa701d88dc45cbaa59a67640 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:38:31 +0800 Subject: [PATCH 36/37] test(tui): wait for scoped file completion The fake terminal retains earlier autocomplete frames, so the directory follow-up assertion could pass before the scoped lookup completed. Wait for the quoted file mention to be applied before submitting. --- packages/ui/tui/tests/tui.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 880d8fd474..2c4fd21650 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1914,10 +1914,11 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Folder · docs/') }) result.terminal.send('\t') - await vi.waitFor(() => { - expect(result.terminal.output).toContain('File · design notes.md') - }) + result.terminal.output = '' result.terminal.send('\t') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('@"docs/design notes.md"') + }) await tick() result.terminal.send('\r') await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) }) From 9e691d54c09173f8074f134008e1eeda7920b491 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:45:23 +0800 Subject: [PATCH 37/37] fix(goal): normalize strict-schema fillers --- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 8 ++- .../2026-07-19-model-facing-goal-tools.zh.md | 8 ++- .../headless-agent/tests/headless.snapshot.ts | 7 +- .../tests/snapshots/goal-tools/input.json | 2 +- .../snapshots/goal-tools/replay.override.json | 10 +++ .../goal-tools/stream-json.expected.jsonl | 66 +++++++++++-------- packages/goal/tool-goal/README.md | 4 +- packages/goal/tool-goal/src/index.ts | 20 +++--- .../goal/tool-goal/tests/tool-goal.spec.ts | 21 ++++++ 10 files changed, 101 insertions(+), 49 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index e53c591aa5..70ca07b414 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: 7cc3907d708115207e166455ea988120a03d768b -2026-07-19-model-facing-goal-tools.zh.md: 1a381160354d6a2a24f957f41bc9e375c1ab01ca +2026-07-19-model-facing-goal-tools.md: 286329390a058c0302520fd2203e5becb8c81395 +2026-07-19-model-facing-goal-tools.zh.md: b0b4fc99ada3597fbab58081f52309e21dd43bac diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index 7cc3907d70..286329390a 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -16,11 +16,11 @@ The surface also needs to preserve the separation between durable state and live ### Tools and model contract -`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. +`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. The executor treats exact empty-string optional fields and a zero `max_goal_rounds` as strict-schema fillers: they count as omitted, an edit still requires at least one meaningful replacement, and all non-filler values retain the action restrictions. The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition. -All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. +All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; mutation cards select meaningful action values before the goal id, so accepted fillers cannot blank their input. Activation is reported only as live observation and is never written into replay state. An autonomous goal round that successfully reports completion or blocking contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not contribute a terminal stop: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary continuation folding. @@ -38,7 +38,7 @@ Complete and blocked accept either direct-human authority or the exact current g ## Testing -Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate. +Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, filler-safe generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/partial-edit/pause/resume behavior including strict-schema fillers, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives a strict-filler `update_goal` probe plus `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate. ## Alternatives considered @@ -48,6 +48,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr - **Authorize from persisted root or fork metadata** — rejected because a fork that becomes an independently resumed top-level session should accept new human authority, while a currently owned child should not. - **Let autonomous rounds edit or resume the goal** — rejected because continuation authority is narrower than authority to redefine or restart the human objective. - **Treat the blocked threshold as an evaluator** — rejected because event counts cannot prove that an obstacle is semantically unchanged or truly terminal. +- **Reject every present action-specific field** — rejected because strict-schema providers can serialize zero-value placeholders for every optional field; only meaningful values can express a conflicting action. ## Consequences @@ -56,6 +57,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr - Human requests can create and rearm goals through ordinary natural language, while restored sessions remain inert until such input arrives. - Goal rounds can finish or report a repeated blocker but cannot broaden their own mandate. - Deployment policy selects the blocking lower bound; the same resolved value controls enforcement and prompt guidance. +- Strict-schema provider fillers interoperate without allowing meaningful cross-action updates. ## Known limitations and deferred work diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 1a38116035..b0b4fc99ad 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -16,11 +16,11 @@ Status: implemented ### 工具与模型契约 -`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。 +`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。执行器把值恰好为空字符串的可选字段和值为 0 的 `max_goal_rounds` 视为严格 schema 占位值:这些值等同于省略;编辑时仍必须提供至少一个有实际意义的替换字段;所有非占位值仍受对应操作的限制。 提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。 -三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 +三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id,因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。 自主目标回合成功报告完成或阻塞后,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免再发起一次不必要的模型请求。直接人类发起的变更不会贡献终止决策:智能体可以确认该变更,并且并发的人类 steering(转向)仍可参与普通的继续执行折叠。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 +单元测试固定注册与释放、独占调度、生成的提示词策略、可安全处理占位值的通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/部分字段编辑/暂停/恢复行为(包括严格 schema 占位值)、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动一次携带严格 schema 占位值的 `update_goal` 探测,以及对 `create_goal` 和 `get_goal` 的调用,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 ## 考虑过的替代方案 @@ -48,6 +48,7 @@ Status: implemented - **根据持久的根或派生元数据授权**——不予采纳,因为成为独立恢复顶层会话的派生应接受新的人类权限,而当前仍受所有权约束的子智能体则不应接受。 - **允许自主回合编辑或恢复目标**——不予采纳,因为继续执行权限比重新定义或重启人类目标的权限更窄。 - **把阻塞阈值当作评估器**——不予采纳,因为事件计数无法证明障碍在语义上未改变或确实不可继续。 +- **拒绝所有已提供的特定操作字段**——不予采纳,因为采用严格 schema 的提供方可能为每个可选字段序列化零值占位符;只有有实际意义的字段值才能表示与指定操作相冲突的另一项操作。 ## 后果 @@ -56,6 +57,7 @@ Status: implemented - 人类可以通过普通自然语言请求创建和重新激活目标,而恢复后的会话在收到此类输入前保持静止。 - 目标回合可以完成或报告重复阻塞,但不能自行扩大任务权限。 - 部署策略选择阻塞下限;同一个解析后的值同时控制执行与提示词指导。 +- 系统可兼容采用严格 schema 的提供方所填入的占位值,同时不会放行有实际意义的跨操作更新。 ## 已知限制与延期工作 diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index a1ce4b6cc5..6852fed20b 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -222,7 +222,12 @@ describe('headless stream-json snapshots', () => { const records = parseJsonl(logs[0]?.content ?? '') const calls = records.filter(record => record.type === 'tool/call') .map(record => (record.data as JsonObject | undefined)?.name) - expect(calls).toEqual(['create_goal', 'get_goal']) + expect(calls).toEqual(['update_goal', 'create_goal', 'get_goal']) + const probeResult = records.find(record => record.type === 'tool/result' + && (record.data as JsonObject | undefined)?.callId === 'call_goal_probe') + const probeData = probeResult?.data as JsonObject | undefined + expect(probeData?.isError).toBe(true) + expect((probeData?.error as JsonObject | undefined)?.code).toBe('GOAL_NOT_FOUND') const goalChanges = records.filter((record) => { if (record.type !== 'user/message') return false const data = record.data as JsonObject | undefined diff --git a/examples/headless-agent/tests/snapshots/goal-tools/input.json b/examples/headless-agent/tests/snapshots/goal-tools/input.json index 5263ccd4e2..8449d44c4c 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/input.json +++ b/examples/headless-agent/tests/snapshots/goal-tools/input.json @@ -2,7 +2,7 @@ "steps": [ { "op": "prompt", - "text": "Create a durable goal to finish the snapshot proof, then inspect it." + "text": "Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it." } ] } diff --git a/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json index aec5204c7d..c4716ba7b0 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json +++ b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json @@ -1,4 +1,14 @@ [ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_probe", "name": "update_goal", "argumentsDelta": "{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_probe", "name": "update_goal", "arguments": "{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}" } }, + { "type": "usage", "usage": { "inputTokens": 15, "outputTokens": 6 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, { "kind": "chunks", "chunks": [ diff --git a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl index 5005192ec8..16518b2d03 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl @@ -1,35 +1,45 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable goal to finish the snapshot proof, then inspect it."}],"source":{"kind":"user"}},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Create a durable goal to","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[1],"source":{"kind":"fallback"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_probe","name":"update_goal","argumentsDelta":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15,"outputTokens":6}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[22],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":15,"outputTokens":6}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true,"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[11],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":23,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"text","text":"GOAL READY"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":32,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":33,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} -{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":85,"outputTokens":14}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":34,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":35,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":41,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"text","text":"GOAL READY"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":42,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":43,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":100,"outputTokens":20}} diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 6e0b25c567..346ac07dcc 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -6,9 +6,9 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal - `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation. - `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution. -- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. Strict-schema empty-string and zero fillers count as omitted, while meaningful values remain limited to their action. -All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. +All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. Mutation cards select the first meaningful action value and otherwise show the goal id, so accepted fillers never produce blank input. All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON. diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 96e6389f85..09953041fa 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -132,13 +132,13 @@ function resolveConfig(config: Config): ResolvedConfig { return { blockedAfterConsecutiveRounds: blockedAfter } } -/** Whether an optional string carries a meaningful action-specific value. */ -function hasText(value: string | undefined): boolean { +/** Whether optional text is meaningful rather than a strict-schema empty filler. */ +function hasText(value: string | undefined): value is string { return value !== undefined && value !== '' } -/** Whether an optional round cap carries a meaningful action-specific value. */ -function hasRoundCap(value: number | undefined): boolean { +/** Whether an optional round cap is meaningful rather than a strict-schema zero filler. */ +function hasRoundCap(value: number | undefined): value is number { return value !== undefined && value !== 0 } @@ -281,13 +281,11 @@ export function apply(ctx: Context, config: Config): void { const execution = goalToolExecution(ctx, exec) const ref = goalRef(args.goal_id, args.revision) const replacements = { - ...args.objective === undefined ? {} : { objective: args.objective }, - ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, + ...hasText(args.objective) ? { objective: args.objective } : {}, + ...hasRoundCap(args.max_goal_rounds) ? { maxGoalRounds: args.max_goal_rounds } : {}, } if (args.action === 'edit') { requireDirectHuman(ctx, execution) - // Some strict-schema providers emit empty placeholders for every declared - // optional field. Reject only values that could change another action. if (hasText(args.blocked_reason)) { throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') } @@ -343,7 +341,11 @@ export function apply(ctx: Context, config: Config): void { presentCall: args => present( `${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`, 'other', - args.blocked_reason ?? args.objective ?? args.goal_id, + hasText(args.blocked_reason) + ? args.blocked_reason + : hasText(args.objective) + ? args.objective + : hasRoundCap(args.max_goal_rounds) ? args.max_goal_rounds : args.goal_id, ), })) } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 8823a6c00a..eaab21e202 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -145,8 +145,17 @@ describe('goal tool registration and presentation', () => { expect(ctx.tools.get('update_goal')?.presentCall?.({ goal_id: 'goal-1', revision: 2, action: 'blocked', blocked_reason: 'Waiting for a human choice.', })).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'Waiting for a human choice.' }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ + goal_id: 'goal-1', revision: 2, action: 'edit', + objective: 'ship', max_goal_rounds: 0, blocked_reason: '', + })).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 'ship' }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ + goal_id: 'goal-1', revision: 2, action: 'edit', + objective: '', max_goal_rounds: 8, blocked_reason: '', + })).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 8 }) expect(ctx.tools.get('update_goal')?.presentCall?.({ goal_id: 'goal-1', revision: 2, action: 'resume', + objective: '', max_goal_rounds: 0, blocked_reason: '', })).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' }) expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined() }) @@ -438,11 +447,23 @@ describe('goal tool state transitions', () => { revision: goal.revision, action: 'edit', objective: 'edited', + max_goal_rounds: 0, blocked_reason: '', }, root.agent) expect(resultGoal(edited)).toMatchObject({ objective: 'edited' }) goal = ctx.goals.get(root.agent)! + const capped = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'edit', + objective: '', + max_goal_rounds: 8, + blocked_reason: '', + }, root.agent) + expect(resultGoal(capped)).toMatchObject({ objective: 'edited', maxGoalRounds: 8 }) + goal = ctx.goals.get(root.agent)! + const paused = await execute(ctx, 'update_goal', { goal_id: goal.id, revision: goal.revision,