Merge branch 'codex/simp-ui-identity-residue' into codex/simp-name-front-door-defaults

This commit is contained in:
Tianyi Cui
2026-07-19 01:57:31 +08:00
58 changed files with 2094 additions and 269 deletions
+13 -4
View File
@@ -34,10 +34,19 @@ sequenceDiagram
Session-->>SDK: <code>session/event</code> <code>assistant/chunk</code>*
Driver->>Hooks: <code>agent/step-result</code> waterfall
Driver->>Session: <code>assistant/message</code>
Driver->>Session: <code>tool/call</code>
Driver->>Tools: execute through pre and post waterfalls
Tools-->>Session: tool-owned events when applicable
Driver->>Session: <code>tool/result</code> and <code>step/end</code>
Driver->>Tools: classify pending call by executionMode
loop barriers and bounded rolling pool, reclassify before start
opt call starts
Driver->>Session: <code>tool/call</code>
Driver->>Tools: ordered pre, concurrent execute
Tools-->>Session: tool-owned events when applicable
end
opt next model-order result ready
Driver->>Tools: ordered post
Driver->>Session: <code>tool/result</code>
end
end
Driver->>Session: <code>step/end</code>
Driver->>Hooks: <code>agent/turn-continuation</code> waterfall
Driver->>Hooks: <code>agent/turn-stop</code> serial terminal checkpoint
Driver->>Session: <code>turn/end</code>
+7 -6
View File
@@ -86,11 +86,12 @@ forever:
'assistant/chunk'
agent/step-result
'assistant/message' (transformed content or empty success anchor after step-result rejection)
each tool call:
'tool/call'
tools/pre-execute -> monotonic guards -> tools/execute -> tools/post-execute -> tools/result
'tool/result'
append post-tool context and steering
schedule tool calls by ctx.tools.executionMode:
exclusive -> one-call barrier
parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start
each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute
each model-order result -> ordered tools/post-execute -> 'tool/result'
append accepted tool-batch context after all recorded results, then steering
'step/end'
agent/turn-continuation
agent/turn-stop (terminal policy)
@@ -101,7 +102,7 @@ forever:
Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
Context accepted during tool execution—including async `agent.inject()` notices and post-tool `additionalContext`—waits for settlement, then follows every recorded result. Successful batches preserve call/result adjacency; interrupted batches drain that context before turn closure. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved.
Context accepted during tool execution—including async `agent.inject()` notices and post-tool `additionalContexts`—waits for settlement, then follows every recorded result. Successful batches preserve call/result adjacency; interrupted batches drain that context before turn closure. Steering drains between steps; ordinary leftover steering becomes queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering while preserving queued prompts.
### Failure Boundaries
+14 -3
View File
@@ -52,6 +52,8 @@ export interface Config {
provider: string
/** Model name for ACP-created agents (must have a registered adapter). */
model: string
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
maxParallelToolCalls?: number
/** Deployment persona (the system-prompt plugin's `persona` config). */
persona?: string
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
@@ -82,8 +84,13 @@ Source: [`packages/examples/acp-demo/src/index.ts:33`](../packages/examples/acp-
Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt`
```ts config-catalog
/** Plugin configuration for declarative startup agents. */
/** Agent-loop plugin configuration. */
export interface Config {
/**
* Maximum parallel-safe calls in flight per agent step. `1` is serial;
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
*/
maxParallelToolCalls?: number
/** Agents created or resumed at plugin startup. */
agents: (AgentOptions & {
/** Stable config label used in logs and as the fresh combined-id prefix. */
@@ -100,7 +107,7 @@ export interface Config {
Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:358`](../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:370`](../packages/core/agent-loop/src/index.ts)
## `@deepseek-ai/dsh-agent-spine-demo`
@@ -123,6 +130,8 @@ Source: [`packages/core/agent-loop/src/index.ts:358`](../packages/core/agent-loo
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
agents?: AgentLoopConfig['agents']
/** Agent-loop concurrency cap; `1` is serial. */
maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls']
/** The deployment persona (see dsh-system-prompt's `Config`). */
persona?: SystemPromptConfig['persona']
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
@@ -791,6 +800,8 @@ export interface Config {
provider: string
/** Model name for the `main` agent (must have a registered adapter). */
model: string
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
maxParallelToolCalls?: number
/** Deployment persona (the system-prompt plugin's `persona` config). */
persona?: string
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
@@ -1199,7 +1210,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'code' | 'both'
```
Source: [`packages/core/tools/src/index.ts:322`](../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/index.ts)
## `@deepseek-ai/dsh-user-approval`
+1 -1
View File
@@ -179,7 +179,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
```
Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:363`](../../packages/core/agent-loop/src/index.ts)
## `approval/*`
+4 -3
View File
@@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<Agent
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
```
Source: [`packages/core/agent-loop/src/index.ts:391`](../../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:408`](../../packages/core/agent-loop/src/index.ts)
## `ctx.agents` — `AgentRegistry`
@@ -321,12 +321,13 @@ restrict(filter: ToolRestriction): () => void
guard(guard: ToolGuard): () => void
get(name: string, scope?: ScopeKey): ToolDefinition | undefined
schemas(scope?: ScopeKey): ToolSchema[]
executionMode(exec: ToolExecutionInput): ToolExecutionMode
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
```
Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:378`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:438`](../../packages/core/tools/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`
+21 -1
View File
@@ -6,7 +6,7 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index
## `ToolDefinition` — a registered tool
A `ToolSchema` (the model-facing fields) plus the `execute` function and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `execute`/`presentCall`/`presentResult` must never leak into a model request.
A `ToolSchema` (the model-facing fields) plus the `execute` function, host-only scheduler metadata, and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `execute`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` must never leak into a model request.
```ts type-equiv
interface ToolDefinition extends ToolSchema {
@@ -19,6 +19,18 @@ interface ToolDefinition extends ToolSchema {
* cooperative implementation that can reach quiescence when the signal aborts.
*/
timeoutMs?: number
/**
* Pure synchronous classifier for overlap with sibling tool calls. Only
* `true` opts in; omission, exceptions, non-`true` returns, and invalid
* `defineTool` arguments are exclusive. This metadata is never model-visible.
*
* Opted-in executions must not mutate parent-owned state. Shared state must
* tolerate concurrent dispatch; recorder races are permitted only when they
* commute or fail closed. See the parallel-tool-call RFC for the full contract.
* @param args - parsed arguments; `defineTool` validates before calling.
* @returns Whether this call may join a parallel group.
*/
isConcurrencySafe?(args: unknown): boolean
/**
* Optional: how to present the PENDING state of one call in a UI, derived from
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
@@ -133,6 +145,14 @@ interface ToolRunContext extends ToolExecution {
}
```
The agent loop asks the registry for each pending call's execution mode and uses it to form exclusive barriers and rolling-pool parallel runs:
```ts type-equiv
type ToolExecutionMode =
| { kind: 'parallel' }
| { kind: 'exclusive' }
```
```ts type-equiv
interface ToolExecution extends ToolExecutionInput {
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
+1 -1
View File
@@ -7,7 +7,7 @@ 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`) | [`stdio`](../packages/ui/stdio) |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:363`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:141`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:150`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
+246 -60
View File
@@ -1,13 +1,85 @@
<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.
Run `pnpm run gen-persistence-catalog` to regenerate. -->
# Persistence Log Event Catalog
# Session Persistence Event Catalog
Every event type that can appear in a session's durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).
Every event type that can appear in a session's durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](core-data-structures/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).
This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).
This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).
The on-disk envelope around every payload is `SessionEvent` `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.
The envelope declarations below compose each event's `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.
## Event envelope
```ts persistence-catalog
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
export type SessionEventType = keyof SessionEventMap
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the ordered surface. Only these
* event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}.
*/
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
* messages.
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
* (inclusive) through `end` (inclusive) with this node. Both must exist as
* surface nodes in the current surface. `start === end` replaces a single
* node. The node's {@link SessionEvent.sourceEventSeqs} must include every
* shadowed surface node. Used by compaction and possible other manipulations.
*/
export type SurfaceOp =
| 'append'
| { op: 'replace'; start: number; end: number }
/**
* One immutable entry in the session log.
*
* A proper discriminated union over `type` (not independent `type`/`data`
* unions), so `switch (event.type)` narrows `event.data` without casts.
*
* 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`).
* Non-surface events (boundary markers, chunks, usage, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.
*/
export type SessionEvent<T extends SessionEventType = SessionEventType> = {
[K in SessionEventType]: {
type: K
/** Monotonic sequence number within the session. */
seq: number
/** Unix epoch milliseconds. */
time: number
data: SessionEventMap[K]
} & (K extends SurfaceEventType ? {
/**
* Seq numbers of events that are provenance sources of this event
* (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
* or the surface nodes shadowed by a compaction replace node). An
* `assistant/message` may carry a present empty array for a known empty
* provider stream; omission means unrecorded provenance.
*/
sourceEventSeqs?: number[]
/** How this event entered the surface; absent for non-surface events. */
surfaceOp?: SurfaceOp
} : object)
}[T]
```
Sources: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts)
## Events
@@ -15,10 +87,21 @@ The on-disk envelope around every payload is `SessionEvent` — `type`, monotoni
#### `approval/asked` — log-only
An approval question was put to the answerer chain — log-only audit (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs it with the `approval/decided` that always follows; `toolName` is the tool the question is about, `callId` the exact tool call when the asker had one, `reason` the asker's human-readable explanation (e.g. a hook's permission-decision reason).
```ts persistence-catalog
'approval/asked': { id: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
/**
* An approval question was put to the answerer chain — log-only audit
* (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs
* it with the `approval/decided` that always follows; `toolName` is the
* tool the question is about, `callId` the exact tool call when the asker
* had one, `reason` the asker's human-readable explanation (e.g. a hook's
* permission-decision reason).
*/
'approval/asked': {
id: ApprovalRequestId
toolName: string
callId?: CallId
reason?: string
}
```
Types: [CallId](core-data-structures/core.md)
@@ -27,19 +110,31 @@ Source: [`packages/ui/user-approval/src/index.ts:45`](../packages/ui/user-approv
#### `approval/decided` — log-only
The outcome of a prior `approval/asked` (same `id`) — log-only audit. Exactly one per ask, appended when the outcome is known: a decision, a cancellation, or the fail-closed `'unavailable'`.
```ts persistence-catalog
'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome }
/**
* The outcome of a prior `approval/asked` (same `id`) — log-only audit.
* Exactly one per ask, appended when the outcome is known: a decision, a
* cancellation, or the fail-closed `'unavailable'`.
*/
'approval/decided': {
id: ApprovalRequestId
outcome: ApprovalOutcome
}
```
Source: [`packages/ui/user-approval/src/index.ts:56`](../packages/ui/user-approval/src/index.ts)
#### `approval/policy` — log-only
The session's approval policy was switched — log-only, durable, replayable, never in the model transcript (the model learns the policy from the prompt section and the narrator's notices). The LAST such event is the session's override (effectiveApprovalPolicy); who asked for it is derivable from position (an event after the log's last `request/header` was a runtime switch by the user).
```ts persistence-catalog
/**
* The session's approval policy was switched — log-only, durable,
* replayable, never in the model transcript (the model learns the policy
* from the prompt section and the narrator's notices). The LAST such
* event is the session's override ({@link effectiveApprovalPolicy});
* who asked for it is derivable from position (an event after the log's
* last `request/header` was a runtime switch by the user).
*/
'approval/policy': { policy: ApprovalPolicy }
```
@@ -49,9 +144,8 @@ Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approv
#### `assistant/chunk` — log-only
Raw stream chunk — token-level replay fidelity.
```ts persistence-catalog
/** Raw stream chunk — token-level replay fidelity. */
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
```
@@ -61,9 +155,13 @@ Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/
#### `assistant/message` — surface
Assembled assistant message for one step (derived history uses this). Carries the step's `usage` when the adapter reported token accounting, so the model output and its accounting travel together (there is no separate usage record). `usage` is absent when the adapter reported none.
```ts persistence-catalog
/**
* Assembled assistant message for one step (derived history uses this).
* Carries the step's `usage` when the adapter reported token accounting, so
* the model output and its accounting travel together (there is no separate
* usage record). `usage` is absent when the adapter reported none.
*/
'assistant/message': { turn: number; step: number; content: ContentBlock[]; provenance: AssistantProvenance; usage?: TokenUsage }
```
@@ -75,9 +173,12 @@ Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/
#### `bash/sandbox-mode` — log-only
Durable log-only sandbox-mode override; never a surface event or model message. Execution and ACP option reporting fold the latest event through effectiveSandboxMode without adding a prompt notice.
```ts persistence-catalog
/**
* Durable log-only sandbox-mode override; never a surface event or model
* message. Execution and ACP option reporting fold the latest event through
* {@link effectiveSandboxMode} without adding a prompt notice.
*/
'bash/sandbox-mode': { mode: SandboxMode }
```
@@ -87,9 +188,8 @@ Source: [`packages/bash/bash/src/session-mode.ts:20`](../packages/bash/bash/src/
#### `compact/end` — log-only
Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed.
```ts persistence-catalog
/** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */
'compact/end': { turn: number; error?: string }
```
@@ -97,9 +197,8 @@ Source: [`packages/compact/compact/src/types.ts:40`](../packages/compact/compact
#### `compact/start` — log-only
Marks the start of a compaction — log-only, holds the lock until `compact/end`.
```ts persistence-catalog
/** Marks the start of a compaction — log-only, holds the lock until `compact/end`. */
'compact/start': { turn: number }
```
@@ -107,10 +206,30 @@ Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact
#### `compact/summary` — log-only
Provenance record of a completed summarization — log-only, no surfaceOp. The summary content is in `data.summary`; the actual surface replacement is performed by a subsequent `user/message` event that shadows the compacted range.
```ts persistence-catalog
'compact/summary': { summary: ContentBlock[]; shadowedRange: { start: number; end: number }; shadowedSeqs: number[]; shadowedTokenCount: number; provider: string; model: string; maxTokens?: number }
/**
* Provenance record of a completed summarization — log-only, no surfaceOp.
* The summary content is in `data.summary`; the actual surface replacement
* is performed by a subsequent `user/message` event that shadows the
* compacted range.
*/
'compact/summary': {
summary: ContentBlock[]
shadowedRange: { start: number; end: number }
shadowedSeqs: number[]
shadowedTokenCount: number
/** The provider route that wrote the summary. */
provider: string
/**
* The model that wrote the summary — the summarize call's envelope,
* reported by the backend that made the call, logged so the one-shot
* request is reconstructable from log + code and "which model wrote
* this summary" has a durable answer (the reconstructability RFC).
*/
model: string
/** The generation cap the summarize call sent, when one applied. */
maxTokens?: number
}
```
Types: [ContentBlock](core-data-structures/core.md)
@@ -121,10 +240,20 @@ Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact
#### `context/message` — surface
In-session context injection (file-change notices, subdir AGENTS.md, skill content, cron notifications, …). Rendered into the derived history as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller own the complete model-facing frame; `meta` is durable JSON state omitted from the model projection.
```ts persistence-catalog
'context/message': { content: ContentBlock[]; source: MessageSource; envelope?: ContextEnvelope; meta?: JsonValue }
/**
* In-session context injection (file-change notices, subdir AGENTS.md,
* skill content, cron notifications, …). Rendered into the derived history
* as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller
* own the complete model-facing frame; `meta` is durable JSON state omitted
* from the model projection.
*/
'context/message': {
content: ContentBlock[]
source: MessageSource
envelope?: ContextEnvelope
meta?: JsonValue
}
```
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
@@ -135,20 +264,44 @@ Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/
#### `hook/invoked` — log-only
A hook command was invoked at a hook point — log-only provenance (like `compact/*`; NOT a SurfaceEventType, carries no `surfaceOp`). `dialect` is the bridge that ran it (`claude`/`codex`), `point` the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group pattern that selected it (absent for match-all), `handlerId` a stable id for the command (so an invoked/result pair correlates). `turn` is the open turn the invocation lives inside.
```ts persistence-catalog
'hook/invoked': { turn: number; point: string; dialect: HookDialect; matcher?: string; handlerId: string }
/**
* A hook command was invoked at a hook point — log-only provenance (like
* `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
* `dialect` is the bridge that ran it (`claude`/`codex`), `point`
* the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group
* pattern that selected it (absent for match-all), `handlerId` a stable id
* for the command (so an invoked/result pair correlates). `turn` is the open
* turn the invocation lives inside.
*/
'hook/invoked': {
turn: number
point: string
dialect: HookDialect
matcher?: string
handlerId: string
}
```
Source: [`packages/hooks/hook-protocol/src/types.ts:19`](../packages/hooks/hook-protocol/src/types.ts)
#### `hook/result` — log-only
Log-only outcome paired to `hook/invoked` by `handlerId`. Decision is the parsed permission result, `stop` for `continue:false`, or `pass`; exit code may be absent, stderr is bounded, and duration is wall-clock runtime.
```ts persistence-catalog
'hook/result': { turn: number; point: string; handlerId: string; decision: string; exitCode?: number; stderrSummary?: string; durationMs: number }
/**
* Log-only outcome paired to `hook/invoked` by `handlerId`. Decision is the
* parsed permission result, `stop` for `continue:false`, or `pass`; exit code
* may be absent, stderr is bounded, and duration is wall-clock runtime.
*/
'hook/result': {
turn: number
point: string
handlerId: string
decision: string
exitCode?: number
stderrSummary?: string
durationMs: number
}
```
Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts)
@@ -157,9 +310,13 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-
#### `permission/preset` — log-only
Records the selected preset as durable, log-only user intent. The knob events follow in the same turn and control execution; this event stays out of the model transcript and lets effectivePermissionPreset preserve a selection when bundles match.
```ts persistence-catalog
/**
* Records the selected preset as durable, log-only user intent. The knob
* events follow in the same turn and control execution; this event stays
* out of the model transcript and lets {@link effectivePermissionPreset}
* preserve a selection when bundles match.
*/
'permission/preset': { preset: string }
```
@@ -169,9 +326,11 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src
#### `prompt/blocked` — log-only
Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, including in a mixed batch.
```ts persistence-catalog
/**
* Durable record of a prompt veto and its reason. It is log-only: the blocked
* prompt never enters the model-visible surface, including in a mixed batch.
*/
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
```
@@ -183,9 +342,11 @@ Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/
#### `request/header` — log-only
Full header for the next request, appended inside its step before dispatch. It is log-only; the latest snapshot reconstructs the request header.
```ts persistence-catalog
/**
* Full header for the next request, appended inside its step before dispatch.
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
```
@@ -195,9 +356,8 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/
#### `steering/message` — surface
Steering content injected between steps of a running turn.
```ts persistence-catalog
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
```
@@ -209,9 +369,8 @@ Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/
#### `step/end` — log-only
Closes step `step` of turn `turn`.
```ts persistence-catalog
/** Closes step `step` of turn `turn`. */
'step/end': { turn: number; step: number }
```
@@ -219,9 +378,8 @@ Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/
#### `step/start` — log-only
Opens step `step` of turn `turn` — one model call plus the tool executions it requested.
```ts persistence-catalog
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
'step/start': { turn: number; step: number }
```
@@ -231,9 +389,8 @@ Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/
#### `todo/write` — log-only
Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history.
```ts persistence-catalog
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
'todo/write': { todos: TodoItem[] }
```
@@ -245,9 +402,12 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/
#### `tool/call` — log-only
The model requested one tool invocation: `name` with the raw `arguments` JSON string exactly as the model produced it (unparsed). `callId` pairs the call with its `tool/result`.
```ts persistence-catalog
/**
* The model requested one tool invocation: `name` with the raw `arguments`
* JSON string exactly as the model produced it (unparsed). `callId` pairs the
* call with its `tool/result`.
*/
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
```
@@ -257,9 +417,22 @@ Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/
#### `tool/code-dispatch` — log-only
One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the deterministic sub-call id (`<parent>:code:<n>`), the tool `name` with its JSON-normalized `arguments` — the exact value dispatched, normalized BEFORE dispatch, so this append can never fail on payload shape — whether the sub-call errored, and a bounded `resultSummary` of its model-facing text. Before bounding, occurrences of a non-root session workspace path are normalized to `.` so host-specific absolute path lengths cannot change the summary. Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter model context; persistence and UIs get every call. Appended inside the parent `run_code`'s execution (the bridge drains its queue before returning), so the turn-enclosure invariant holds by construction.
```ts persistence-catalog
/**
* One bridged sub-dispatch from a `run_code` program: the parent
* `run_code` call id, the deterministic sub-call id
* (`<parent>:code:<n>`), the tool `name` with its JSON-normalized
* `arguments` — the exact value dispatched, normalized BEFORE dispatch,
* so this append can never fail on payload shape — whether the sub-call
* errored, and a bounded `resultSummary` of its model-facing text. Before
* bounding, occurrences of a non-root session workspace path are
* normalized to `.` so host-specific absolute path lengths cannot change
* the summary.
* Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
* model context; persistence and UIs get every call. Appended inside the
* parent `run_code`'s execution (the bridge drains its queue before
* returning), so the turn-enclosure invariant holds by construction.
*/
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
```
@@ -269,9 +442,16 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c
#### `tool/result` — surface
A completed tool call's model-facing result, plus an optional tool-private `meta` presentation payload. `meta` is opaque to the core (`unknown` — the producing tool owns its shape and reads it back in `presentResult`) but MUST be JSON-serializable: `Session.append` runtime-validates all event data with `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the durable log reproduces the identical card on replay. Absent unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here).
```ts persistence-catalog
/**
* A completed tool call's model-facing result, plus an optional tool-private
* `meta` presentation payload. `meta` is opaque to the core (`unknown` — the
* producing tool owns its shape and reads it back in `presentResult`) but MUST
* be JSON-serializable: `Session.append` runtime-validates all event data with
* `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the
* durable log reproduces the identical card on replay. Absent unless the tool
* attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here).
*/
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown }
```
@@ -283,9 +463,12 @@ Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/
#### `turn/end` — log-only
Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awaited `session/flush` checkpoint at every turn end, so the turn boundary is also the durable-commit boundary.
```ts persistence-catalog
/**
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
* fires the awaited `session/flush` checkpoint at every turn end, so the turn
* boundary is also the durable-commit boundary.
*/
'turn/end': { turn: number; reason: TurnEndReason }
```
@@ -295,9 +478,13 @@ Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/
#### `turn/start` — log-only
Opens turn `turn`. `trigger` records what started it — a drained message batch or an idle-time injection. The turn is the durability/replay boundary: every event sits between a `turn/start` and its matching `turn/end` (the turn-enclosure invariant).
```ts persistence-catalog
/**
* Opens turn `turn`. `trigger` records what started it — a drained message
* batch or an idle-time injection. The turn is the durability/replay
* boundary: every event sits between a `turn/start` and its matching
* `turn/end` (the turn-enclosure invariant).
*/
'turn/start': { turn: number; trigger: TurnTrigger }
```
@@ -309,9 +496,8 @@ Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/
#### `user/message` — surface
A user-visible prompt (queued message drained at turn start).
```ts persistence-catalog
/** A user-visible prompt (queued message drained at turn start). */
'user/message': { content: ContentBlock[]; source: MessageSource }
```
+1
View File
@@ -80,6 +80,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 |
| [Bash-backed grep and glob discovery tools](implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md) | 2026-07-09 |
| [Expose agent session identity and JSONL location to tools and hooks](implemented/feature/2026-07-10-agent-session-identity-and-log-location.md) | 2026-07-10 |
| [Parallel tool-call execution by per-call safety](implemented/feature/2026-07-10-parallel-tool-call-execution.md) | 2026-07-10 |
| [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 |
| [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 |
| [Session query relationship tracing](implemented/feature/2026-07-13-session-query-tracing.md) | 2026-07-13 |
@@ -0,0 +1,101 @@
# RFC: Parallel tool-call execution by per-call safety
Status: implemented
## Problem
An assistant message may contain several sibling `tool-call` blocks. Running them serially adds the latency of independent reads and web requests even though the model has already requested them together.
Concurrency is a host scheduling concern, not model-facing tool metadata. The loop needs to decide which calls may overlap without hardcoding tool names or exposing scheduler policy in the JSON schema.
The session log remains authoritative: every started call has an audit event, every started call receives a result, and model history observes results in the original call order regardless of completion order.
## Decision
Each tool may provide an optional `isConcurrencySafe(args)` classifier. It is synchronous and pure: it examines only the current call's parsed arguments and performs no I/O or mutation. Only an explicit `true` opts in; a missing classifier, invalid arguments, a thrown classifier, or any other return value makes the call exclusive. The canonical type contract lives in the [tool data structures](../../../core-data-structures/tools.md).
The classifier is deliberately unary. Returning `true` is the tool's promise that this call may overlap with any sibling call that also returns `true`; the scheduler does not compare calls or prove that their resource accesses are compatible.
The unary classifier remains input-sensitive. A tool may classify a read-only operation as parallel and a mutating operation as exclusive. The interface cannot express relational rules such as "these writes are safe only when their paths differ," so a call whose safety depends on a sibling remains exclusive.
`defineTool()` validates arguments before invoking a typed classifier. Invalid arguments classify as exclusive and produce the ordinary argument error only if the call executes. `ctx.tools.executionMode(exec)` resolves the live tool definition and returns the tagged `parallel` or `exclusive` mode; unknown tools fail closed to exclusive.
A tagged mode, rather than a public boolean scheduler API, keeps resource-aware variants representable without changing the classifier contract.
## Scheduling and ordering
The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. Classification is lazy: the scheduler resolves the next call after each barrier and reclassifies every later call before replenishing a parallel pool. If a registry mutation makes that call exclusive, the current pool drains before the call starts as the next barrier.
For example:
```text
[parallel read(A), parallel read(B), exclusive write(A), parallel read(C)]
→ [read(A), read(B)]
→ [write(A)]
→ [read(C)]
```
`read(A)` and `read(B)` may overlap. `write(A)` starts after both finish, and `read(C)` starts after the write finishes.
Every group uses a rolling pool bounded by `maxParallelToolCalls`: the loop starts calls in model order up to the cap and starts another whenever one settles. An exclusive group is a pool of one. A cap of `1` preserves serial execution.
Only dispatch and the tool body overlap. `tools/pre-execute` and `tools/post-execute` run in model order because middleware may maintain ordering-sensitive state. `tools/execute` wrappers run around concurrent dispatches and therefore must be reentrant across distinct executions.
Each started call appends `tool/call` immediately before its pre-execute gate. Completed dispatches occupy model-order slots, and a commit cursor appends `tool/result` and collects `additionalContexts` only when the next slot is ready. Live surfaces may show several pending calls, but results and post-tool context remain model-ordered.
An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event.
Code Mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler.
## Safety contract
A tool that returns `true` promises that its body is safe to run at the same time as other parallel calls. It must not directly mutate the parent session or other parent-owned state; it returns its outputs to the loop, which commits them in model order.
Any shared state touched during execution must be concurrency-safe. This includes tool wrappers and providers: they may serialize internally or enforce their own capacity, but they must support concurrent dispatch without corrupting state.
## Configuration and declarations
`maxParallelToolCalls` is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to `10`; `1` preserves serial execution. Exact fields and defaults live in the generated [configuration catalog](../../../config-catalog.md).
The shipped declarations are conservative. Web search, web fetch, and filesystem read opt in. Filesystem writes and edits, bash tools, subagent delegation, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. A subagent may share its parent's workspace or external resources, and the unary classifier cannot prove that sibling delegations have disjoint effects. Bash has no proven input-sensitive classifier and remains exclusive.
Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces `FS_STALE_VERSION`.
## Verification
Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, and abort draining. First-party tests pin each parallel declaration.
Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. Code Mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior.
## Alternatives considered
**Keep serial execution.** This avoids new ordering and abort cases but retains unnecessary latency for independent sibling calls.
**Use one tool-level boolean.** A fixed `supportsParallelToolCalls` flag is smaller but cannot distinguish a tool's read-only and mutating operations. The argument-sensitive classifier preserves that distinction.
**Use stateful classification.** Giving the classifier a live agent, registry, or I/O access makes the decision depend on when it runs and creates a gap between classification and dispatch. Mutable authorization and stale-state checks remain execution-time responsibilities.
**Use sibling-aware or resource-aware classification.** The scheduler could compare calls pairwise or let each call declare resource read/write claims. This can parallelize non-conflicting writes, but it requires shared resource identity and conflict semantics across unrelated tools. The unary contract instead gives up that concurrency and fails closed when safety is relational.
**Parallelize the complete tool pipeline.** This keeps the loop on the public one-call API but runs pre- and post-execute middleware concurrently. Existing guards and hook bridges may carry ordered state, so only dispatch overlaps.
**Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` leaves an insertion point for a policy seam.
**Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete.
**Use fixed-size windows.** Waiting for every call in one window before starting the next leaves capacity idle behind a slow call. The rolling pool preserves the cap without that delay.
**Expose concurrency metadata to the model.** The model can already emit sibling calls. Host scheduling metadata would enlarge requests without improving tool choice.
## Consequences
The design is fail-closed and simple for tool authors, but it cannot exploit concurrency whose safety depends on comparing siblings. A tool that opts in too broadly can expose latent shared-state races.
Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler therefore records only started calls, drains them on abort, and never starts replacements after cancellation.
Ordered commits may hold a fast result behind a slow earlier sibling. This preserves replay and model-history order while live surfaces still show pending progress.
Concurrent external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step.
Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool.
@@ -4,19 +4,19 @@ Status: implemented
## Problem
`SessionEventMap` is the on-disk vocabulary, but its declarations are split across the owning session package and declaration merges. The generated persistence catalog is the single reference for every event and payload; hand-maintained tables drift and are removed. These records are not Cordis events—observers receive them through the single `session/event` bus event—so the Cordis catalog cannot cover them. The generator discovers all declarations and the doc-sync freshness gate rejects omissions or stale output.
`SessionEventMap` is the on-disk vocabulary, but its declarations are split across the owning session package and declaration merges. The generated persistence catalog is the single reference for every event, its complete payload declaration and source JSDoc, and the shared `SessionEvent` envelope; hand-maintained tables drift and are removed. These records are not Cordis events—observers receive them through the single `session/event` bus event—so the Cordis catalog cannot cover them. The generator discovers all declarations and the doc-sync freshness gate rejects omissions or stale output.
## Decision
Generate `docs/persistence-catalog.md` from source, with a freshness gate, as the fourth reference surface: the *records* a persisted session log can contain, complementing the cordis catalog (wiring), core-data-structures (vocabulary), and the tool catalog (tools).
`gen-persistence-catalog.ts` scans every owning and declaration-merged `SessionEventMap` with the TypeScript AST. It renders source JSDoc, payload type, derived surface badge, reference links, and source location. The doc-sync freshness check rejects a vocabulary change whose catalog was not regenerated.
`gen-persistence-catalog.ts` scans every owning and declaration-merged `SessionEventMap` with the TypeScript AST. It renders each member from its leading JSDoc through the complete payload type, retaining nested property comments and removing only its containing indentation, and also pastes the owning `SessionEventType`, `SurfaceEventType`, `SurfaceOp`, and `SessionEvent` declarations that compose the persisted envelope. Derived surface badges, reference links, and source locations remain outside the declaration blocks. The doc-sync freshness check rejects a vocabulary or envelope change whose catalog was not regenerated.
Specific choices:
- **JSDoc completeness, enforced.** Every member must carry description prose — the JSDoc becomes the catalog entry, the same forcing function the cordis catalog applies to bus events. An `@mode` tag on a member is a hard error: dispatch modes belong to cordis bus events, and a log event has none — the tag would misread as "this fires on the bus with mode X". Violations aggregate into one error listing every offender.
- **JSDoc completeness, enforced.** Every member and rendered envelope type must carry description prose, and the full source JSDoc stays attached to its declaration in the catalog. An `@mode` tag is a hard error: dispatch modes belong to cordis bus events, and persisted records have none. Violations aggregate into one error listing every offender.
- **The surface badge is derived, not hand-listed.** `SurfaceEventType` — the subset that produces LLM messages and may carry `surfaceOp` — is parsed from its union declaration in the owning package; a union member naming no declared event is a hard error (a stale union member would otherwise silently badge nothing). Everything else renders **log-only**.
- **A dedicated fence.** Payload blocks use a ` ```ts persistence-catalog ` info string that `doc-typecheck` recognizes and skips, excluded from the opt-out ratio — the same treatment as `ts cordis-catalog` (a bare payload fragment is not standalone-compilable).
- **A dedicated fence.** Declaration blocks use a ` ```ts persistence-catalog ` info string that `doc-typecheck` recognizes and skips, excluded from the opt-out ratio — the same treatment as `ts cordis-catalog` (the declarations reference types from their owning modules and are not standalone-compilable).
- **Repo scope.** The catalog enumerates the packages in this repo, matching the siblings' packages-only scope; a downstream plugin can merge further event types, which are outside the catalog by construction. The walk defends its own assumptions with hard errors: the owning top-level `interface SessionEventMap` must be the single exported declaration in `@deepseek-ai/dsh-session` (an unrelated, local, or duplicate same-named interface cannot be catalogued as the on-disk vocabulary), no declaration may carry `extends` (inherited keys would join `keyof SessionEventMap` without a catalog row), every member must be a property signature with an explicit payload type (a method-form member would join `keyof` yet slip past a silent walk), and a duplicate member across declarations fails.
This supersedes the hand-copies: the session.md `hook/*` table, the compact README's event table, the hook-protocol README's payload bullets, and the session README's name-list now link the catalog instead of restating payloads (the surrounding semantics prose stays where it was). The two stray `@mode emit` tags on the hook-protocol merge members are removed — the new gate rejects them as the category error they were.
@@ -28,7 +28,7 @@ This supersedes the hand-copies: the session.md `hook/*` table, the compact READ
## Consequences
- The catalog cannot drift: a vocabulary change the committed file doesn't reflect fails `verify-persistence-catalog` in the pre-push hook and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type.
- Event prose has a single home, the JSDoc at the declaration; thin JSDoc yields a thin catalog entry, pressuring authors to document at the source.
- The catalog cannot drift: a vocabulary or envelope change the committed file doesn't reflect fails `verify-persistence-catalog` in the pre-push hook and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type.
- Event prose has a single home, the JSDoc at the declaration; the catalog preserves that JSDoc and any nested field comments without flattening or paraphrasing them.
- The `SurfaceEventType` union is now structurally load-bearing for docs: renaming an event without updating the union (or vice versa) fails the generator, not just the compiler.
- The badge derivation assumes the union stays a closed set of string literals with exactly one owner; a refactor away from that shape must update the generator in the same change.
+7
View File
@@ -53,6 +53,13 @@ const SCENARIOS: Scenario[] = [
// Its prompt and tool-schema sidecars pin the composed header.
{ name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
{ name: 'tool-call-turn', hasModelTurn: true, recorded: true },
{
name: 'parallel-tool-calls',
hasModelTurn: true,
recorded: false,
headerClass: 'fs',
configPath: FS_CONFIG,
},
{ name: 'bash-spill', hasModelTurn: true, recorded: false, headerClass: 'fs', configPath: FS_CONFIG },
{ name: 'fs-terminal-card', hasModelTurn: true, recorded: true },
{ name: 'todo-plan', hasModelTurn: true, recorded: true },
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE." }
]
}
@@ -0,0 +1,28 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"}
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_read_a","name":"read","argumentsDelta":"{\"file_path\":\"a.txt\"}"}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}}}
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_read_b","name":"read","argumentsDelta":"{\"file_path\":\"b.txt\"}"}}}
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}}}
{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"}
{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}
{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}
{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","content":[{"type":"text","text":"<path>{{cwd}}/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[13],"surfaceOp":"append"}
{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","content":[{"type":"text","text":"<path>{{cwd}}/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}}
{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":1}}}}
{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":1}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":26,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,8 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_a","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_b","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
@@ -0,0 +1 @@
alpha
@@ -262,6 +262,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
'guard(guard: ToolGuard): () => void',
'get(name: string, scope?: ScopeKey): ToolDefinition | undefined',
'schemas(scope?: ScopeKey): ToolSchema[]',
'executionMode(exec: ToolExecutionInput): ToolExecutionMode',
'async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>',
],
},
@@ -1128,7 +1129,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolDefinition',
declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
},
{
name: 'ToolErrorInfo',
@@ -1146,6 +1147,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ToolExecutionInput',
declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n}',
},
{
name: 'ToolExecutionMode',
declaration: 'export type ToolExecutionMode = {\n kind: \'parallel\';\n} | {\n kind: \'exclusive\';\n};',
},
{
name: 'ToolExecutionResult',
declaration: 'export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n}',
+5 -2
View File
@@ -31,6 +31,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
```ts
interface Config {
maxParallelToolCalls?: number // default 10; 1 is serial
agents: Array<{
id: string // required
provider?: string
@@ -41,7 +42,7 @@ interface Config {
}
```
Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
### Exported concrete class
@@ -57,6 +58,8 @@ Every provider call that reaches a successful finish appends exactly one `assist
Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path.
### What belongs to plugins
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
@@ -83,7 +86,7 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p
## Known Limitations and Deferred Work
- **Tool calls within a step execute sequentially** — parallel execution waits on concurrency-safety metadata in the tool contract (see `dsh-tools`).
- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
- **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-<uuid>` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history.
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
- **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin.
+12 -2
View File
@@ -54,15 +54,20 @@ export interface PreparedReactLoopAgent {
* @param id - the concrete agent identity.
* @param options - loop options for the agent.
* @param session - the prepared session the agent will own.
* @param maxParallelToolCalls - resolved in-flight cap for this agent.
* @returns the agent and closures bound only to that exact instance.
*/
export function prepareReactLoopAgent(
ctx: Context, id: SessionId, options: AgentOptions, session: Session,
ctx: Context,
id: SessionId,
options: AgentOptions,
session: Session,
maxParallelToolCalls: number,
): PreparedReactLoopAgent {
if (claimedDriverSessions.has(session)) {
throw new Error(`session "${session.id}" already has a concrete agent driver`)
}
const agent = new ReactLoopAgent(ctx, id, options, session)
const agent = new ReactLoopAgent(ctx, id, options, session, maxParallelToolCalls)
claimedDriverSessions.add(session)
const dispose = () => agent[stopDriver]()
return {
@@ -143,6 +148,8 @@ export class ReactLoopAgent implements Agent {
* the `disposed` transition fires and leave the promise hanging.
*/
private idleWaiters: (() => void)[] = []
/** Maximum parallel-safe calls allowed in one step. */
private readonly maxParallelToolCalls: number
/**
* Durability checkpoints started by idle {@link inject} calls. `inject()` is
* synchronous, so it cannot await them itself; the driver disposer drains
@@ -159,7 +166,9 @@ export class ReactLoopAgent implements Agent {
public readonly id: SessionId,
public readonly options: AgentOptions,
public readonly session: Session,
maxParallelToolCalls: number,
) {
this.maxParallelToolCalls = maxParallelToolCalls
const { promise, resolve } = Promise.withResolvers<void>()
this.disposed = promise
this.resolveDisposed = resolve
@@ -380,6 +389,7 @@ export class ReactLoopAgent implements Agent {
this.driverStarted = true
this.done = runLoop(this.loopCtx, this, {
inbox: this.#inbox,
maxParallelToolCalls: this.maxParallelToolCalls,
setStatus: (status) => { this.setStatus(status) },
setAbort: controller => void (this.currentAbort = controller),
disposed: this.disposed,
@@ -0,0 +1,6 @@
/** Shared agent-loop scheduler defaults.
* @module dsh-agent-loop/constants
*/
/** Default maximum in-flight parallel-safe calls per agent step. */
export const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10
+29 -6
View File
@@ -31,6 +31,7 @@ import {
ReactLoopAgent,
} from './agent.ts'
import type { PreparedReactLoopAgent } from './agent.ts'
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
export { ReactLoopAgent } from './agent.ts'
@@ -97,6 +98,15 @@ function signalAbortError(id: SessionId, signal: AbortSignal): Error {
return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
}
/** Resolve the deployment-wide scheduler cap at the owning config boundary. */
function resolveMaxParallelToolCalls(value: number | undefined): number {
const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1) {
throw new Error('maxParallelToolCalls must be a positive integer')
}
return maxParallelToolCalls
}
/**
* Caller-owned create/resume transaction through rollback-covered publication
* and quiescent teardown. Resources remain private until the final registry
@@ -187,13 +197,13 @@ class AgentCreationTransaction {
}
/** Construct the driver and scope, then install their complete ordered lifecycle. */
prepare(options: AgentOptions, session: Session): ReactLoopAgent {
prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent {
this.assertActive()
const gate = Promise.withResolvers<void>()
this.preparing = gate.promise
try {
this.session = session
const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session)
const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session, maxParallelToolCalls)
this.driver = driver
const agent = driver.agent
const scope = createScope(this.loopCtx, agent)
@@ -354,8 +364,15 @@ declare module 'cordis' {
}
}
/** Plugin configuration for declarative startup agents. */
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
/** Agent-loop plugin configuration. */
export interface Config {
/**
* Maximum parallel-safe calls in flight per agent step. `1` is serial;
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
*/
maxParallelToolCalls?: number
/** Agents created or resumed at plugin startup. */
agents: (AgentOptions & {
/** Stable config label used in logs and as the fresh combined-id prefix. */
@@ -393,6 +410,7 @@ export class AgentLoop extends Service implements AgentFactory {
/** Runtime schema for declarative agents. */
static Config = z.object({
maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
agents: z.array(z.object({
id: z.string().required(),
sessionId: z.string().min(1),
@@ -404,12 +422,15 @@ export class AgentLoop extends Service implements AgentFactory {
}) as unknown as z<Config>
private readonly ownership: FactoryOwnership
/** Resolved concurrency cap for every driver created by this factory. */
private readonly maxParallelToolCalls: number
/** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
private readonly runtime: { ctx: Context }
constructor(ctx: Context, public config: Config) {
super(ctx, 'agentLoop')
validateConfiguredAgents(config.agents)
this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls)
this.ownership = new FactoryOwnership(ctx.fiber)
this.runtime = { ctx }
ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
@@ -524,7 +545,7 @@ export class AgentLoop extends Service implements AgentFactory {
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
try {
const session = loopCtx.sessions.prepare(id, { meta })
const agent = transaction.prepare(options, session)
const agent = transaction.prepare(options, session, this.maxParallelToolCalls)
transaction.publish('startup')
return agent
} catch (error: unknown) {
@@ -542,6 +563,7 @@ export class AgentLoop extends Service implements AgentFactory {
* @returns the published handle.
*/
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
const agentOptions = options.agentOptions ?? {}
const transaction = new AgentCreationTransaction(
this.runtime.ctx,
ownerCtx,
@@ -554,7 +576,7 @@ export class AgentLoop extends Service implements AgentFactory {
...options.seed === undefined ? {} : { seed: options.seed },
...options.meta === undefined ? {} : { meta: options.meta },
})
const agent = transaction.prepare(options.agentOptions ?? {}, session)
const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
await transaction.waitFor(options.setup?.(agent.ctx))
transaction.assertActive()
return transaction.publish('startup')
@@ -586,6 +608,7 @@ export class AgentLoop extends Service implements AgentFactory {
persistence: SessionPersistence,
options: ResumeAgentOptions,
): Promise<AgentHandle> {
const agentOptions = options.agentOptions ?? {}
const transaction = new AgentCreationTransaction(
this.runtime.ctx,
ownerCtx,
@@ -605,7 +628,7 @@ export class AgentLoop extends Service implements AgentFactory {
...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength },
},
})
const agent = transaction.prepare(options.agentOptions ?? {}, session)
const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
await transaction.waitFor(options.setup?.(agent.ctx))
transaction.assertActive()
return transaction.publish('resume')
+7 -40
View File
@@ -18,6 +18,7 @@ import type { TransmissionLog } from './request-log.ts'
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 { ReactLoopAgent } from './agent.ts'
import type { Inbox } from './inbox.ts'
@@ -73,6 +74,8 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
export interface LoopHandle {
/** Native-private agent inbox handed to the driver only at internal startup. */
readonly inbox: Inbox
/** Maximum parallel-safe calls allowed in one step. */
readonly maxParallelToolCalls: number
setStatus(status: 'idle' | 'running'): void
setAbort(controller: AbortController | undefined): void
/** Resolves when the agent is disposed — unblocks the idle wait. */
@@ -559,49 +562,13 @@ async function runStep(
// empty chunk provenance for a contentless, usage-less provider response.
recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs)
// Tool execution stays sequential; recheck abort around each normalized result.
// Dispatch may overlap; policy, durable results, and result context stay model-ordered.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish }
return handle.withToolBatch(async (acceptContext) => {
for (const call of toolCalls) {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
let parsedArguments: unknown
try {
parsedArguments = call.arguments ? JSON.parse(call.arguments) : {}
} catch {
parsedArguments = call.arguments
}
// TODO(pre-tool-input-rewrite): Keep logged history and live presentation aligned;
// see docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md.
const result = await ctx.tools.execute({
callId: call.id,
name: call.name,
arguments: parsedArguments,
agent,
signal,
})
session.append('tool/result', {
turn, step,
// Correlation comes from the immutable execution input; the result does
// not duplicate this authoritative transcript identity.
callId: call.id,
content: result.content,
isError: result.isError,
...result.error ? { error: result.error } : {},
// Persist tool-owned presentation data for replay.
...result.meta !== undefined ? { meta: result.meta } : {},
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
// Accept into the batch FIFO immediately; entries remain deferred until
// every recorded result settles and survive abort or disposal afterward.
for (const context of result.additionalContexts ?? []) acceptContext(context)
// The signal may flip while the tool is awaited.
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
/* v8 ignore stop */
}
await executeToolCalls(
ctx, agent, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext,
)
return { hadToolCalls: true, finish: assembler.finish }
})
}
+229
View File
@@ -0,0 +1,229 @@
/**
* Schedules one assistant step's tool calls. Exclusive calls form barriers;
* parallel calls use a bounded rolling pool and are reclassified before start.
* Dispatch may overlap, while policy, results, and result context remain
* model-ordered. Abort stops replenishment and drains started calls.
*
* Each started call records `tool/call`; `tool/result` commits in model order,
* preserving derived history when audit events interleave with earlier results.
* @module dsh-agent-loop/tool-calls
*/
import type { Context } from 'cordis'
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
import type { HookContext } from '@deepseek-ai/dsh-agent'
import type { Session } from '@deepseek-ai/dsh-session'
import { TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
import type { ReactLoopAgent } from './agent.ts'
/** One tool call after argument parsing, ready to schedule. */
interface PlannedCall {
block: ToolCallBlock
exec: ToolExecutionInput
}
/** Settled dispatch awaiting model-order finalization. */
interface Slot {
exec: ToolRunContext
result: ToolExecutionResult
needsPost: boolean
}
/**
* Schedule one assistant step's tool calls by their live concurrency mode.
* Started calls receive ordered results. Abort drains them and rethrows after
* accepting their context into the batch FIFO owned by the caller.
*
* @param ctx - loop context that owns the tool registry.
* @param agent - agent and session receiving the call lifecycle.
* @param turn - current turn number.
* @param step - current step number.
* @param toolCalls - assistant calls in model order.
* @param signal - abort signal shared by the step.
* @param maxParallel - validated in-flight cap.
* @param acceptContext - accepts committed result context into the active batch.
*/
export async function executeToolCalls(
ctx: Context,
agent: ReactLoopAgent,
turn: number,
step: number,
toolCalls: ToolCallBlock[],
signal: AbortSignal,
maxParallel: number,
acceptContext: (context: HookContext) => void,
): Promise<void> {
const { session } = agent
// Inputs are distinct because tools/execute wrappers may replace `exec.signal`.
const planned: PlannedCall[] = toolCalls.map(block => ({
block,
exec: {
callId: block.id,
name: block.name,
arguments: parseArguments(block.arguments),
agent,
signal,
},
}))
let next = 0
while (next < planned.length) {
// Commit before classifying again so registry changes affect unstarted calls.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
const first = planned[next]!
const mode = ctx.tools.executionMode(first.exec).kind
const group = mode === 'parallel' ? planned.slice(next) : [first]
next += await runGroup(ctx, session, turn, step, group, mode, signal, maxParallel, acceptContext)
}
}
/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */
function parseArguments(raw: string): unknown {
try {
return raw ? JSON.parse(raw) : {}
} catch {
return raw
}
}
/**
* Run one exclusive barrier or parallel pool. Later calls are reclassified
* before start; an exclusive reclassification waits for the current pool to
* drain and remains for the caller's next barrier. Results and contexts commit
* in model order. Abort stops starts, drains and commits started calls, accepts
* their contexts into the owning batch, and throws.
*/
async function runGroup(
ctx: Context,
session: Session,
turn: number,
step: number,
group: PlannedCall[],
mode: ToolExecutionMode['kind'],
signal: AbortSignal,
maxParallel: number,
acceptContext: (context: HookContext) => void,
): Promise<number> {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const slots: (Slot | undefined)[] = group.map(() => undefined)
// Started slots retain their tool/call seq for result provenance.
const callSeqs: number[] = group.map(() => -1)
let nextToStart = 0
let committed = 0
let started = 0
let aborted: boolean = signal.aborted
// `committed` advances only across contiguous model-order slots.
const commitReady = async (): Promise<void> => {
while (committed < group.length) {
const slot = slots[committed]
if (slot === undefined) break
const call = group[committed]
const result = slot.needsPost
? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(slot.exec, slot.result)
: ctx.tools[TOOL_REGISTRY_SCHEDULER].finish(slot.exec, slot.result)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!)
for (const context of result.additionalContexts ?? []) acceptContext(context)
committed++
}
}
const inFlight = new Map<number, Promise<number>>()
const startCall = async (index: number): Promise<void> => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
const call = group[index]!
callSeqs[index] = appendToolCall(session, turn, step, call.block)
started++
const prepared = await ctx.tools[TOOL_REGISTRY_SCHEDULER].prepare(call.exec)
switch (prepared.kind) {
case 'dispatch': {
const promise = ctx.tools[TOOL_REGISTRY_SCHEDULER].dispatch(prepared.exec).then((outcome) => {
slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' }
return index
})
inFlight.set(index, promise)
break
}
case 'post-result':
slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: true }
break
case 'final-result':
slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: false }
break
/* v8 ignore next -- closed-union exhaustiveness guard */
default:
assertNever(prepared, 'tool-call scheduler prepare result')
}
}
const fillPool = async (): Promise<void> => {
while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) {
// Re-read later modes after ordered commits so registry changes can create a barrier.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
const nextCall = group[nextToStart]!
if (nextToStart > 0 && mode === 'parallel'
&& ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break
await startCall(nextToStart)
nextToStart++
await commitReady()
// Abort may arrive while pre-execute awaits.
if (signal.aborted) aborted = true
}
}
// Ordered pre-execute may await; only dispatch/body overlaps.
// TODO: Drain every started call before rethrowing a scheduler error; tool
// bodies must not outlive the failed turn.
await fillPool()
while (inFlight.size > 0) {
const settledIndex = await Promise.race(inFlight.values())
inFlight.delete(settledIndex)
await commitReady()
// Abort may arrive while a tool or ordered commit awaits.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (signal.aborted) aborted = true
await fillPool()
}
if (aborted) {
// Started calls and accepted context settle before the turn records the abort.
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
throw new Error(String(signal.reason ?? 'aborted'))
}
/* v8 ignore next -- unreachable: a non-aborted group commits every started call */
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
return started
}
/** Append a started call and return its provenance sequence. */
function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): number {
const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments })
return event.seq
}
/** Append a model-ordered result linked to its call event. */
function appendToolResult(
session: Session,
turn: number,
step: number,
block: ToolCallBlock,
result: ToolExecutionResult,
callSeq: number,
): void {
session.append('tool/result', {
turn, step,
// Correlation stays with the loop's authoritative model-transcript call id;
// registry results deliberately do not duplicate it.
callId: block.id,
content: result.content,
isError: result.isError,
...result.error ? { error: result.error } : {},
// The tool's private presentation payload (e.g. a result-time diff),
// persisted so a UI bridge reproduces the card on replay.
...result.meta !== undefined ? { meta: result.meta } : {},
}, { surfaceOp: 'append', sourceEventSeqs: [callSeq] })
}
+16 -6
View File
@@ -5,7 +5,7 @@ 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 from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -52,10 +52,14 @@ describe('ReactLoopAgent', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('exclusive-driver'))
const prepared = prepareReactLoopAgent(ctx, SessionId('first-driver'), { provider: 'mock', model: 'mock' }, session)
const prepared = prepareReactLoopAgent(
ctx, SessionId('first-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
)
expect(() => prepared.agent.ctx).toThrow('context is not bound')
expect(() => prepareReactLoopAgent(ctx, SessionId('second-driver'), { provider: 'mock', model: 'mock' }, session))
expect(() => prepareReactLoopAgent(
ctx, SessionId('second-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
))
.toThrow('already has a concrete agent driver')
await prepared.dispose()
@@ -253,7 +257,9 @@ describe('ReactLoopAgent', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('test'))
const prepared = prepareReactLoopAgent(ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session)
const prepared = prepareReactLoopAgent(
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
)
const { agent } = prepared
prepared.markPublished()
@@ -271,7 +277,9 @@ describe('ReactLoopAgent', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('pre-start-dispose'))
const prepared = prepareReactLoopAgent(ctx, SessionId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session)
const prepared = prepareReactLoopAgent(
ctx, SessionId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
)
await prepared.dispose()
expect(prepared.agent.status).toBe('disposed')
@@ -368,7 +376,9 @@ describe('ReactLoopAgent', () => {
const adapter = new MockAdapter(['hang'])
ctx.llm.registerAdapter(['mock'], adapter)
const session = ctx.sessions.create(SessionId('bare'))
const prepared = prepareReactLoopAgent(ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session)
const prepared = prepareReactLoopAgent(
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
)
const { agent } = prepared
prepared.markPublished()
const dispose = prepared.startDriver()
@@ -5,7 +5,7 @@ import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -822,7 +822,9 @@ describe('turn numbering continues across seeded sessions', () => {
ctx2.llm.registerAdapter(['mock'], second)
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
const prepared = prepareReactLoopAgent(ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded)
const prepared = prepareReactLoopAgent(
ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
)
const forked = prepared.agent
prepared.markPublished()
ctx2.effect(() => prepared.startDriver())
@@ -0,0 +1,571 @@
/**
* Exercises scheduler ordering and cancellation with deterministic gated tools.
* ACP goldens own transcript-facing coverage.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import LlmService from '@deepseek-ai/dsh-llm'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, {
agents: [],
...maxParallelToolCalls === undefined ? {} : { maxParallelToolCalls },
})
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
})
})
}
function events(agent: ReactLoopAgent): SessionEvent[] {
return [...agent.session.events]
}
/** Build one assistant response containing the supplied tool calls. */
function multiCall(calls: { id: string; name: string; args: object }[]): StreamChunk[] {
const chunks: StreamChunk[] = []
calls.forEach((call, index) => {
chunks.push(
{ type: 'block-start', index, blockType: 'tool-call' },
{ type: 'block-end', index, block: { type: 'tool-call', id: CallId(call.id), name: call.name, arguments: JSON.stringify(call.args) } },
)
})
chunks.push(
{ type: 'usage', usage: { inputTokens: 5, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
)
return chunks
}
/** A tool whose calls block until the test releases them by callId. */
function gatedTool(name: string, parallel: boolean) {
const gates = new Map<string, () => void>()
const started: string[] = []
const tool = defineTool({
name,
description: `gated ${name}`,
parameters: { id: { type: 'string', required: true } },
...parallel ? { isConcurrencySafe: () => true } : {},
async execute(args) {
started.push(args.id)
await new Promise<void>((resolve) => { gates.set(args.id, resolve) })
return [{ type: 'text', text: `done-${args.id}` }]
},
})
return {
tool,
started,
release(id: string) { gates.get(id)?.(); gates.delete(id) },
pending() { return [...gates.keys()] },
}
}
function gatedParallelTool(name: string) {
return gatedTool(name, true)
}
function gatedExclusiveTool(name: string) {
return gatedTool(name, false)
}
/** Poll until `predicate` holds, letting microtasks/timers drain between checks. */
async function until(predicate: () => boolean): Promise<void> {
for (let i = 0; i < 1000 && !predicate(); i++) await new Promise(r => setTimeout(r, 0))
if (!predicate()) throw new Error('until: condition never held')
}
describe('tool-call scheduler: grouping and barriers', () => {
it('runs parallel-safe siblings concurrently (all start before any completes)', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }, { id: 'c3', name: 'p', args: { id: '3' } }]),
textResponse('done'),
])
const ctx = await harness(adapter)
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' }])
await until(() => gated.started.length === 3)
expect(gated.started).toEqual(['1', '2', '3'])
gated.release('1'); gated.release('2'); gated.release('3')
await waitForIdle(ctx, agent)
})
it('an exclusive call between two parallel-safe calls forms a barrier (3 groups)', async () => {
const order: string[] = []
const adapter = new MockAdapter([
multiCall([
{ id: 'c1', name: 'r', args: { id: 'A1' } },
{ id: 'c2', name: 'w', args: { id: 'A2' } },
{ id: 'c3', name: 'r', args: { id: 'A3' } },
]),
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'r', description: 'read', parameters: { id: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute(args) { order.push(`r-start-${args.id}`); order.push(`r-end-${args.id}`); return [{ type: 'text', text: 'r' }] },
}))
ctx.tools.register(defineTool({
name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } },
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' }])
await waitForIdle(ctx, agent)
expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3'])
})
it('reclassifies pending calls after an exclusive barrier replaces their tool', async () => {
const adapter = new MockAdapter([
multiCall([
{ id: 'c1', name: 'replace', args: { id: '0' } },
{ id: 'c2', name: 'x', args: { id: '1' } },
{ id: 'c3', name: 'x', args: { id: '2' } },
]),
textResponse('done'),
])
const ctx = await harness(adapter)
const replacement = gatedExclusiveTool('x')
const disposeSafe = ctx.tools.register(defineTool({
name: 'x',
description: 'initially safe',
parameters: { id: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute(args) { return [{ type: 'text', text: `old-${args.id}` }] },
}))
ctx.tools.register(defineTool({
name: 'replace',
description: 'replace x',
parameters: { id: { type: 'string', required: true } },
async execute() {
disposeSafe()
ctx.tools.register(replacement.tool)
return [{ type: 'text', text: 'replaced' }]
},
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await until(() => replacement.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(replacement.started).toEqual(['1'])
replacement.release('1')
await until(() => replacement.started.length === 2)
expect(replacement.started).toEqual(['1', '2'])
replacement.release('2')
await waitForIdle(ctx, agent)
})
it('stops replenishing when a result observer makes the next call exclusive', async () => {
const adapter = new MockAdapter([
multiCall([
{ id: 'c1', name: 'x', args: { id: '1' } },
{ id: 'c2', name: 'x', args: { id: '2' } },
{ id: 'c3', name: 'x', args: { id: '3' } },
]),
textResponse('done'),
])
const ctx = await harness(adapter, 2)
const initial = gatedParallelTool('x')
const replacement = gatedExclusiveTool('x')
const disposeInitial = ctx.tools.register(initial.tool)
ctx.on('tools/result', (exec) => {
if (exec.callId !== CallId('c1')) return
disposeInitial()
ctx.tools.register(replacement.tool)
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await until(() => initial.started.length === 2)
initial.release('1')
await until(() => events(agent).some(event =>
event.type === 'tool/result' && event.data.callId === CallId('c1')))
await new Promise(r => setTimeout(r, 5))
expect(replacement.started).toEqual([])
initial.release('2')
await until(() => replacement.started.length === 1)
expect(replacement.started).toEqual(['3'])
replacement.release('3')
await waitForIdle(ctx, agent)
})
})
describe('tool-call scheduler: model-order results despite out-of-order settlement', () => {
it('commits tool/result in model order even when a later call settles first', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('done'),
])
const ctx = await harness(adapter)
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' }])
await until(() => gated.started.length === 2)
gated.release('2')
await new Promise(r => setTimeout(r, 5))
const beforeFirst = events(agent).filter(e => e.type === 'tool/result')
expect(beforeFirst).toEqual([])
gated.release('1')
await waitForIdle(ctx, agent)
const results = events(agent).filter(e => e.type === 'tool/result')
expect(results.map(e => e.data.callId)).toEqual([CallId('c1'), CallId('c2')])
})
it('derived history pairs calls in model order regardless of tool/call log interleaving', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('done'),
])
const ctx = await harness(adapter)
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' }])
await until(() => gated.started.length === 2)
gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
const messages = agent.session.deriveMessages()
const toolResults = messages.flatMap(m => m.content.filter(b => b.type === 'tool-result'))
expect(toolResults.map(b => b.toolCallId)).toEqual([CallId('c1'), CallId('c2')])
})
})
describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => {
it('rejects invalid global maxParallelToolCalls config at plugin load', async () => {
await expect(harness(new MockAdapter([]), 0)).rejects.toThrow()
await expect(harness(new MockAdapter([]), 1.5)).rejects.toThrow()
})
it('defensively rejects invalid caps when direct construction bypasses the config schema', () => {
expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 0 }))
.toThrow('maxParallelToolCalls must be a positive integer')
expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 1.5 }))
.toThrow('maxParallelToolCalls must be a positive integer')
})
it('defaults the cap when direct construction bypasses the config schema', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
expect(() => new AgentLoop(ctx, { agents: [] })).not.toThrow()
await ctx.fiber.dispose()
})
it('starts at most the cap, replenishing as calls settle', async () => {
const adapter = new MockAdapter([
multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))),
textResponse('done'),
])
const ctx = await harness(adapter, 2)
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' }])
await until(() => gated.started.length === 2)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1', '2'])
gated.release('1')
await until(() => gated.started.length === 3)
expect(gated.started).toEqual(['1', '2', '3'])
expect(events(agent)
.filter(e => e.type === 'tool/call' || e.type === 'tool/result')
.map(e => `${e.type}:${String(e.data.callId)}`)
.slice(0, 4))
.toEqual(['tool/call:c1', 'tool/call:c2', 'tool/result:c1', 'tool/call:c3'])
gated.release('2'); gated.release('3')
await until(() => gated.started.length === 4)
gated.release('4')
await waitForIdle(ctx, agent)
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
})
it('maxParallelToolCalls: 1 is fully serial (no second start before the first settles)', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('done'),
])
const ctx = await harness(adapter, 1)
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' }])
await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1'])
gated.release('1')
await until(() => gated.started.length === 2)
gated.release('2')
await waitForIdle(ctx, agent)
})
it('applies the configured cap to every factory-created agent', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('done'),
])
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 })
ctx.llm.registerAdapter(['mock'], adapter)
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' }])
await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1'])
gated.release('1')
await until(() => gated.started.length === 2)
gated.release('2')
await waitForIdle(ctx, agent)
})
})
describe('tool-call scheduler: ordered middleware and additional contexts', () => {
it('tools/pre-execute and tools/post-execute observe model call order', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }, { id: 'c3', name: 'p', args: { id: '3' } }]),
textResponse('done'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const pre: string[] = []
const post: string[] = []
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => { pre.push(String(exec.callId)); return next() })
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { post.push(String(exec.callId)); return next() })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 3)
gated.release('3'); gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
expect(pre).toEqual([CallId('c1'), CallId('c2'), CallId('c3')].map(String))
expect(post).toEqual([CallId('c1'), CallId('c2'), CallId('c3')].map(String))
})
it('injects additional contexts in model call order, not settlement order', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('done'),
])
const ctx = await harness(adapter, 2)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
({ 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' }])
await until(() => gated.started.length === 2)
gated.release('2'); gated.release('1')
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)
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')
expect(lastResult).toBeLessThan(firstContext)
})
it('orders pre-execute denials and errors without dispatching them', async () => {
const adapter = new MockAdapter([
multiCall([
{ id: 'c1', name: 'p', args: { id: '1' } },
{ id: 'c2', name: 'p', args: { id: '2' } },
{ id: 'c3', name: 'p', args: { id: '3' } },
]),
textResponse('done'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const post: string[] = []
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.callId === CallId('c2')) return { kind: 'deny', reason: 'blocked by policy' }
if (exec.callId === CallId('c3')) throw new Error('pre exploded')
return next()
})
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
post.push(String(exec.callId))
return next()
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 1)
gated.release('1')
await waitForIdle(ctx, agent)
expect(gated.started).toEqual(['1'])
expect(post).toEqual(['c1', 'c2'])
const results = events(agent).filter(e => e.type === 'tool/result')
expect(results.map(e => e.data.callId)).toEqual([CallId('c1'), CallId('c2'), CallId('c3')])
expect((results[1]!.data.content[0] as { text: string }).text).toContain('blocked by policy')
expect((results[2]!.data.content[0] as { text: string }).text).toContain('pre exploded')
})
})
describe('tool-call scheduler: abort handling', () => {
it('starts no calls when the signal is already aborted before a parallel group', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('should never be requested'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'assistant/message') {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('already aborted')
}
})
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(gated.started).toEqual([])
expect(events(agent).filter(e => e.type === 'tool/call')).toEqual([])
expect(events(agent).filter(e => e.type === 'tool/result')).toEqual([])
})
it('stops starting siblings when abort fires during ordered pre-execute', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('should never be requested'),
])
const ctx = await harness(adapter)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.callId === CallId('c1')) {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('pre cancelled')
}
return next()
})
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1'])
gated.release('1')
await waitForIdle(ctx, agent)
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
.toEqual([CallId('c1')])
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
.toEqual([CallId('c1')])
})
it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => {
const adapter = new MockAdapter([
multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))),
textResponse('should never be requested'),
])
const ctx = await harness(adapter, 2)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => ({
...await next(),
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' }])
await until(() => gated.started.length === 2)
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop now')
gated.release('1')
gated.release('2')
await waitForIdle(ctx, agent)
expect(gated.started).toEqual(['1', '2'])
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2')])
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2')])
const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message')
expect(settled.map(e => e.type))
.toEqual(['tool/result', 'tool/result', 'context/message', 'context/message'])
expect(settled.filter(e => e.type === 'context/message')
.map(e => (e.data.content[0] as { text: string }).text))
.toEqual(['ctx-c1', 'ctx-c2'])
})
it('does not run an exclusive barrier after a parallel group aborts', async () => {
const adapter = new MockAdapter([
multiCall([
{ id: 'c1', name: 'p', args: { id: '1' } },
{ id: 'c2', name: 'p', args: { id: '2' } },
{ id: 'c3', name: 'x', args: { id: '3' } },
]),
textResponse('should never be requested'),
])
const ctx = await harness(adapter, 2)
const gated = gatedParallelTool('p')
const exclusive: string[] = []
ctx.tools.register(gated.tool)
ctx.tools.register(defineTool({
name: 'x',
description: 'exclusive',
parameters: { id: { type: 'string', required: true } },
async execute(args) { exclusive.push(args.id); return [{ type: 'text', text: 'x' }] },
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop before barrier')
gated.release('1')
gated.release('2')
await waitForIdle(ctx, agent)
expect(exclusive).toEqual([])
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2')])
})
})
@@ -9,6 +9,7 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
annotateSurface,
collectEventEnvelopeTypes,
collectLogEvents,
collectSurfaceEventTypes,
render,
@@ -56,6 +57,7 @@ describe('gen-persistence-catalog collectLogEvents', () => {
scope: 'fix',
doc: 'A thing was recorded.',
payload: '{ turn: number }',
declaration: '/** A thing was recorded. */\n\'fix/happened\': { turn: number }',
source: 'packages/core/fix/src/types.ts:3',
})
})
@@ -102,10 +104,13 @@ describe('gen-persistence-catalog collectLogEvents', () => {
it('collapses a newline-separated multi-line payload to a valid one-line fragment', () => {
const events = collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(
' /** Wide payload. */\n \'fix/wide\': {\n alpha: string[]\n range: { start: number; end: number }\n count: number\n }',
' /** Wide payload. */\n \'fix/wide\': {\n /** Alpha values. */\n alpha: string[]\n range: { start: number; end: number }\n count: number\n }',
),
}))
expect(events[0]?.payload).toBe('{ alpha: string[]; range: { start: number; end: number }; count: number }')
expect(events[0]?.declaration).toBe(
'/** Wide payload. */\n\'fix/wide\': {\n /** Alpha values. */\n alpha: string[]\n range: { start: number; end: number }\n count: number\n}',
)
})
it('hard-errors on a member with no description prose', () => {
@@ -158,6 +163,59 @@ describe('gen-persistence-catalog collectLogEvents', () => {
})
})
describe('gen-persistence-catalog collectEventEnvelopeTypes', () => {
const declarations = `/** Event keys. */
export type SessionEventType = keyof SessionEventMap
/** Surface-producing event keys. */
export type SurfaceEventType = 'fix/message'
/** Surface placement. */
export type SurfaceOp = 'append'
/** One persisted event. */
export type SessionEvent<T extends SessionEventType = SessionEventType> = { type: T }
`
it('extracts the envelope declarations with their complete JSDoc in canonical order', () => {
const entries = collectEventEnvelopeTypes(make({
'packages/core/fix/package.json': OWNER_MANIFEST,
'packages/core/fix/src/types.ts': declarations,
}))
expect(entries.map(entry => entry.name)).toEqual([
'SessionEventType',
'SurfaceEventType',
'SurfaceOp',
'SessionEvent',
])
expect(entries[3]).toMatchObject({
declaration: '/** One persisted event. */\nexport type SessionEvent<T extends SessionEventType = SessionEventType> = { type: T }',
source: 'packages/core/fix/src/types.ts:8',
})
})
it('hard-errors when an envelope declaration is missing', () => {
expect(() => collectEventEnvelopeTypes(make({
'packages/core/fix/package.json': OWNER_MANIFEST,
'packages/core/fix/src/types.ts': declarations.replace('/** Surface placement. */\nexport type SurfaceOp = \'append\'\n', ''),
}))).toThrow(/missing event-envelope declaration\(s\): SurfaceOp/)
})
it('hard-errors on duplicate, unexported, undocumented, or mistagged envelope declarations', () => {
const violations = new RegExp([
'4 JSDoc completeness violation\\(s\\)',
'[\\s\\S]*not exported',
'[\\s\\S]*@mode tag',
'[\\s\\S]*SurfaceOp.*no description prose',
'[\\s\\S]*SessionEvent.*already declared',
].join(''))
expect(() => collectEventEnvelopeTypes(make({
'packages/core/fix/package.json': OWNER_MANIFEST,
'packages/core/fix/src/types.ts': declarations
.replace('/** Event keys. */\nexport type SessionEventType', '/** Event keys.\n * @mode emit\n */\ntype SessionEventType')
.replace('/** Surface placement. */\n', '')
+ '/** Duplicate event. */\nexport type SessionEvent = { type: never }\n',
}))).toThrow(violations)
})
})
describe('gen-persistence-catalog collectSurfaceEventTypes', () => {
it('parses the literal union', () => {
const types = collectSurfaceEventTypes(make({
@@ -192,9 +250,21 @@ describe('gen-persistence-catalog annotateSurface + render', () => {
scope: name.split('/')[0] ?? name,
payload: '{ turn: number }',
doc: `Records ${name}.`,
declaration: `/** Records ${name}. */\n'${name}': { turn: number }`,
source: 'packages/core/fix/src/types.ts:3',
})
const envelopeTypes = [
'SessionEventType',
'SurfaceEventType',
'SurfaceOp',
'SessionEvent',
].map(name => ({
name: name as 'SessionEventType' | 'SurfaceEventType' | 'SurfaceOp' | 'SessionEvent',
declaration: `/** ${name}. */\nexport type ${name} = never`,
source: 'packages/core/fix/src/types.ts:1',
}))
it('badges union members surface and everything else log-only', () => {
const annotated = annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message'])
expect(annotated.map(e => [e.name, e.surface])).toEqual([['fix/message', true], ['fix/marker', false]])
@@ -205,11 +275,13 @@ describe('gen-persistence-catalog annotateSurface + render', () => {
.toThrow(/'fix\/ghost' name no declared log event/)
})
it('renders badges, payload fences, and the generated-file header', () => {
const out = render(annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message']))
it('renders badges, declaration fences, and the generated-file header', () => {
const out = render(annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message']), envelopeTypes)
expect(out).toContain('Generated by scripts/gen-persistence-catalog.ts')
expect(out).toContain('# Session Persistence Event Catalog')
expect(out).toContain('```ts persistence-catalog\n/** SessionEventType. */\nexport type SessionEventType = never')
expect(out).toContain('#### `fix/message` — surface')
expect(out).toContain('#### `fix/marker` — log-only')
expect(out).toContain('```ts persistence-catalog\n\'fix/marker\': { turn: number }\n```')
expect(out).toContain('```ts persistence-catalog\n/** Records fix/marker. */\n\'fix/marker\': { turn: number }\n```')
})
})
+9 -2
View File
@@ -21,6 +21,7 @@ tools:
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`.
- `ctx.tools.executionMode(exec)` returns `parallel` only when the visible definition's `isConcurrencySafe(exec.arguments)` classifier returns exactly `true`; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive.
### Injected services
@@ -32,7 +33,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec)`, optional presentation callbacks, and cooperative `timeoutMs`.
- `ToolDefinition``ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
@@ -87,6 +88,8 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an
Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema.
Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. Exact `true` permits concurrent dispatch/body execution; invalid input and all other outcomes remain exclusive. Opted-in bodies do not mutate parent-owned state, and shared-state races must commute or fail closed. The [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the full safety contract.
### Structured-output schema subset
`StructuredOutputSchema` is the object-rooted raw JSON Schema subset used by subagents and workflows for machine-readable results. It accepts one scalar `type`, object `properties`/`required`/boolean `additionalProperties`, array `items`, and scalar `enum`/`const`. The annotations `description`, `title`, `default`, and `examples` are ignored but must remain JSON data. Type arrays, undeclared required keys, and unsupported keywords fail through `OutputSchemaError` rather than being ignored; `validateStructuredValue()` returns path-qualified violations without throwing.
@@ -108,6 +111,10 @@ Under `code` or `both`, the registry exposes the reserved `run_code` transport a
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/envelope/meta even when the program later fails.
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
### Parallel execution
The agent loop groups consecutive `parallel` calls into a bounded rolling pool and treats each `exclusive` call as an ordering barrier. Only dispatch/body overlaps; policy, durable results, and context retain model order. Code Mode bindings remain serial. The [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the shipped declarations and rationale.
## Model Experience
### Normal tool schemas
@@ -145,7 +152,7 @@ The available tools:
## Known Limitations and Deferred Work
- **Native tool calls execute sequentially** — `ToolDefinition` carries no concurrency-safety metadata; adding it (and parallel execution in the loop) waits on the deferred tool-shapes review (`TODO(review)`).
- **Concurrency policy is not an event seam** — `executionMode()` reads the resolved tool definition directly; plugins can only declare a classifier on definitions they own.
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and never applies `default` (`XXX(unused-default)` flags removing that field); raw-registered JSON-Schema tools validate their own input.
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
+234 -86
View File
@@ -117,9 +117,6 @@ declare module 'cordis' {
}
}
// TODO(concurrency): revisit these shapes when concurrency metadata becomes useful
// (for example, a read-only hint that would permit safe parallel execution).
/** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */
export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
@@ -134,6 +131,20 @@ export interface ToolDefinition extends ToolSchema {
* cooperative implementation that can reach quiescence when the signal aborts.
*/
timeoutMs?: number
/**
* Pure synchronous classifier for overlap with sibling tool calls. Only
* `true` opts in; omission, exceptions, non-`true` returns, and invalid
* `defineTool` arguments are exclusive. This metadata is never model-visible.
*
* Opted-in executions must not mutate parent-owned state. Shared state must
* tolerate concurrent dispatch; recorder races are permitted only when they
* commute or fail closed. See the
* [parallel-tool-call RFC](../../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md)
* for the full contract.
* @param args - parsed arguments; `defineTool` validates before calling.
* @returns Whether this call may join a parallel group.
*/
isConcurrencySafe?(args: unknown): boolean
/**
* Optional: how to present the PENDING state of one call in a UI, derived from
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
@@ -195,6 +206,14 @@ export interface ToolExecutionInput {
signal?: AbortSignal
}
/**
* Scheduling mode for one pending call. `parallel` may overlap with siblings;
* `exclusive` runs alone and forms an ordering barrier.
*/
export type ToolExecutionMode =
| { kind: 'parallel' }
| { kind: 'exclusive' }
/**
* One pending tool call inside the registry pipeline. Parsed arguments cross
* one lossless-JSON materialization boundary before policy and are deep-frozen;
@@ -222,6 +241,47 @@ export interface ToolRunContext extends ToolExecution {
deferContext(context: HookContext): void
}
/**
* Scheduler-only result after ordered pre-execute and guards. A `post-result`
* still receives post-execute; a `final-result` bypasses it.
* @internal
*/
export type ScheduledToolPreparation =
| { kind: 'dispatch'; exec: ToolRunContext }
| { kind: 'post-result'; exec: ToolRunContext; result: ToolExecutionResult }
| { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult }
/**
* Scheduler-only dispatch result. A `post-result` still receives post-execute;
* a `final-result` already matches {@link ToolRegistry.execute} failure semantics.
* @internal
*/
export type ScheduledToolDispatch =
| { kind: 'post-result'; result: ToolExecutionResult }
| { kind: 'final-result'; result: ToolExecutionResult }
/**
* Symbol-keyed scheduler view that keeps pre/post policy ordered while
* overlapping dispatch. Ordinary callers use {@link ToolRegistry.execute};
* this is not a plugin seam.
* @internal
*/
export interface ToolRegistryScheduler {
/** Materialize input, run the ordered pre-execute/guard gate, and decide what stage follows. */
prepare(exec: ToolExecutionInput): Promise<ScheduledToolPreparation>
/** Run only the around-dispatch/body stage. */
dispatch(exec: ToolRunContext): Promise<ScheduledToolDispatch>
/** Run ordered post-execute finalization, then materialize and notify the final outcome. */
finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult>
/** Materialize and notify a final outcome that must bypass post-execute. */
finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult
}
/**
* Scheduler entry point omitted from the generated named service API.
* @internal
*/
export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
export interface ToolErrorInfo {
name: string
@@ -382,6 +442,16 @@ export class ToolRegistry extends Service {
mode: z.union(['native', 'code', 'both'] as const).default('native'),
})
/** Internal staged view consumed by `dsh-agent-loop`'s parallel scheduler. */
readonly [TOOL_REGISTRY_SCHEDULER]: ToolRegistryScheduler = {
prepare: exec => this.prepareScheduledExecution(exec),
dispatch: exec => this.dispatchScheduledExecution(exec),
finalize: (exec, result) => this.finalizeScheduledExecution(exec, result),
finish: (exec, result) => this.finishScheduledExecution(exec, result),
}
/** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
private global = new Map<string, ToolDefinition>()
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
/** Compiled restriction filters, per scope (see {@link restrict}). */
@@ -682,6 +752,24 @@ export class ToolRegistry extends Service {
}
}
/**
* Classify a pending call through the caller's visible tool definition. Only
* an exact `true` is parallel; unknown, hidden, undeclared, invalid, or
* throwing classifiers are exclusive.
* @param exec - call name, parsed arguments, and optional agent scope.
* @returns the fail-closed scheduling mode.
*/
executionMode(exec: ToolExecutionInput): ToolExecutionMode {
const tool = this.get(exec.name, exec.agent)
if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
try {
const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments)
return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' }
} catch {
return { kind: 'exclusive' }
}
}
/**
* Execute through pre-policy, guards, around-dispatch, post-policy, and final
* notification. Tool and listener failures resolve as materialized error
@@ -692,6 +780,28 @@ export class ToolRegistry extends Service {
* @returns the materialized final result.
*/
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
return this.prepareExecution(exec, prepared => this.completeScheduledExecution(prepared))
}
private async completeScheduledExecution(prepared: ScheduledToolPreparation): Promise<ToolExecutionResult> {
switch (prepared.kind) {
case 'dispatch': {
const dispatched = await this.dispatchScheduledExecution(prepared.exec)
return dispatched.kind === 'post-result'
? await this.finalizeScheduledExecution(prepared.exec, dispatched.result)
: this.finishScheduledExecution(prepared.exec, dispatched.result)
}
case 'post-result':
return await this.finalizeScheduledExecution(prepared.exec, prepared.result)
case 'final-result':
return this.finishScheduledExecution(prepared.exec, prepared.result)
/* v8 ignore next -- closed-union exhaustiveness guard */
default:
return assertNever(prepared, 'scheduled tool preparation')
}
}
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: ToolRunContext } {
const deferredContexts: HookContext[] = []
const token = createExecutionToken()
const callId = exec.callId
@@ -710,105 +820,143 @@ export class ToolRegistry extends Service {
deferredContexts.push(context)
},
}
let execution: ToolRunContext
try {
const detached = snapshotJsonValue(exec.arguments)
if (detached === undefined) {
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
}
execution = {
...base,
arguments: deepFreeze(detached),
}
const execution: ToolRunContext = { ...base, arguments: deepFreeze(detached) }
this.deferredContexts.set(execution, deferredContexts)
return { kind: 'ready', exec: execution }
} catch (error: unknown) {
execution = { ...base, arguments: undefined }
const result = this.materializeFinalResult(toolErrorResult(error))
this.notifyResult(execution, result)
return result
const execution: ToolRunContext = { ...base, arguments: undefined }
return { kind: 'final-result', exec: execution, result: toolErrorResult(error) }
}
let result: ToolExecutionResult
}
/**
* Run the ordered pre-execute and monotonic guard stages for the scheduler.
* @param input - the caller-supplied execution input.
* @returns the prepared execution plus the next scheduler stage.
* @internal
*/
private async prepareScheduledExecution(input: ToolExecutionInput): Promise<ScheduledToolPreparation> {
return this.prepareExecution(input, prepared => prepared)
}
private async prepareExecution<T>(
input: ToolExecutionInput,
next: (prepared: ScheduledToolPreparation) => T | PromiseLike<T>,
): Promise<T> {
const created = this.createExecution(input)
if (created.kind !== 'ready') return next(created)
const exec = created.exec
try {
result = this.materializeFinalResult(await this.executePipeline(execution, deferredContexts))
const carrier = scopeTarget(this, exec.agent)
const gate = await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
const denialReason = decision.kind === 'allow'
? this.guardReason(exec)
: decision.reason
if (denialReason !== undefined) {
return await next({
kind: 'post-result',
exec,
result: {
content: [{ type: 'text', text: `Error: ${denialReason}` }],
isError: true,
},
})
}
return await next({ kind: 'dispatch', exec })
} catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener, guard, or the
// waterfall machinery becomes an isError result, never a turn failure.
result = this.materializeFinalResult(toolErrorResult(error))
return next({ kind: 'final-result', exec, result: toolErrorResult(error) })
}
this.notifyResult(execution, result)
return result
}
/** Run the transformable pipeline; {@link execute} owns final normalization and notification. */
private async executePipeline(exec: ToolRunContext, deferredContexts: HookContext[]): Promise<ToolExecutionResult> {
// --- Gate: tools/pre-execute. An `ask` resolves through the optional
// approval seam (or degrades to deny) before the monotonic guards run. The
// carrier keys dispatch by exec.agent, so an `agent.ctx` listener gates only
// its own agent's calls (agent-less calls are subject-less).
const carrier = scopeTarget(this, exec.agent)
const gate = await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
const denialReason = decision.kind === 'allow'
? this.guardReason(exec)
: decision.reason
if (denialReason !== undefined) {
// Every non-grant, including a failed/unavailable approval request, takes
// the same deny path and still reaches post-policy plus result observers.
const denied: ToolExecutionResult = {
content: [{ type: 'text', text: `Error: ${denialReason}` }],
isError: true,
}
return await this.postExecute(exec, denied)
}
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
// with-normalization thunk — the tool body's own try/catch turns a throw
// into an isError result so a wrapper (and post-execute) can inspect it;
// an unknown tool routes through the same catch. A `tools/execute` listener
// (e.g. a timeout plugin) wraps this thunk: it may replace `exec.signal`
// before delegating and inspect the normalized result after. Dispatched with the
// same carrier as the gate, so an `agent.ctx` wrapper wraps only its own
// agent's calls. ---
const result = await this.ctx.waterfall(
carrier, 'tools/execute', exec,
async (): Promise<ToolExecutionResult> => {
try {
// Resolve through the CALLER's visible view ({@link get}): a scoped
// tool shadows its global name-twin for that agent, and a
// restricted-away global tool is exactly as absent as a nonexistent
// one — same UNKNOWN_TOOL result, no capability leak in the error.
const tool = this.get(exec.name, exec.agent)
if (!tool) throw new ToolNotFoundError(exec.name)
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
// presentation payload). An array IS the content; the object carries it.
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
return { content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
return toolErrorResult(error)
/**
* Run around-dispatch and the tool body. Tool and unknown-tool failures still
* receive post-execute; pipeline failures are already final.
* @param exec - the prepared execution.
* @returns whether the result still needs post-execute.
* @internal
*/
private async dispatchScheduledExecution(exec: ToolRunContext): Promise<ScheduledToolDispatch> {
try {
const carrier = scopeTarget(this, exec.agent)
const result = await this.ctx.waterfall(
carrier, 'tools/execute', exec,
async (): Promise<ToolExecutionResult> => {
try {
const tool = this.get(exec.name, exec.agent)
if (!tool) throw new ToolNotFoundError(exec.name)
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
return { content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
return toolErrorResult(error)
}
},
)
const deferredContexts = this.deferredContexts.get(exec)
/* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution')
const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
? result
: {
...result,
additionalContexts: [
...deferredContexts,
...result.additionalContexts ?? [],
],
}
},
)
const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
? result
: {
...result,
additionalContexts: [
...deferredContexts,
...result.additionalContexts ?? [],
],
}
return await this.postExecute(exec, resultWithDeferredContexts)
return { kind: 'post-result', result: resultWithDeferredContexts }
} catch (error: unknown) {
return { kind: 'final-result', result: toolErrorResult(error) }
}
}
/** Notify final-result observers without giving them a mutation/error channel into the outcome. */
/**
* Run ordered post-execute, then materialize and notify the final outcome.
* @param exec - the prepared execution.
* @param result - dispatch/pre result that still needs post-execute.
* @returns the materialized final result.
* @internal
*/
private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult> {
try {
return this.finishScheduledExecution(exec, await this.postExecute(exec, result))
} catch (error: unknown) {
return this.finishScheduledExecution(exec, toolErrorResult(error))
}
}
/**
* Materialize and notify a final result that must bypass post-execute.
* @param exec - the prepared execution.
* @param result - final result.
* @returns the materialized final result.
* @internal
*/
private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
let finalResult: ToolExecutionResult
try {
finalResult = this.materializeFinalResult(result)
} catch (error: unknown) {
finalResult = this.materializeFinalResult(toolErrorResult(error))
}
this.notifyResult(exec, finalResult)
return finalResult
}
/** Notify observers without exposing a mutation or error channel into the outcome. */
private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
// The pipeline is over: freeze the remaining mutable signal slot so every
// observer sees the SAME WeakMap-keyable execution without a mutation race.
// Freeze the remaining mutable signal slot before observers receive the
// shared WeakMap-keyable execution object.
Object.freeze(exec)
const callbacks = this.ctx.events.dispatch('emit', [
scopeTarget(this, exec.agent), 'tools/result', exec, result,
+18 -1
View File
@@ -283,6 +283,14 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* is never sent to the model.
*/
readonly timeoutMs?: number
/**
* Optional pure synchronous classifier for sibling overlap. It receives typed
* arguments after soft validation; invalid input returns `false` without
* invoking it. See {@link ToolDefinition.isConcurrencySafe}.
* @param args - typed validated arguments.
* @returns whether this call may join a parallel group.
*/
isConcurrencySafe?(args: InferArgs<S>): boolean
/**
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
@@ -315,7 +323,7 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* @param options - the tool's name, description, typed parameter schema,
* execute body, and optional presenters.
* @returns a registry-ready definition with strict execution validation and
* soft presenter validation for replay compatibility.
* soft presenter and classifier validation for replay compatibility.
*/
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
// Object-literal execute methods don't use `this`; the reference is safe.
@@ -325,6 +333,8 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
const userPresentCall = options.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentResult = options.presentResult
// eslint-disable-next-line @typescript-eslint/unbound-method
const userIsConcurrencySafe = options.isConcurrencySafe
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
}
@@ -359,5 +369,12 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
return userPresentResult(args as InferArgs<S>, result)
}
}
// Invalid arguments fail closed without invoking the typed classifier.
if (userIsConcurrencySafe) {
tool.isConcurrencySafe = (args: unknown): boolean => {
if (validateArgs(options.parameters, args).length > 0) return false
return userIsConcurrencySafe(args as InferArgs<S>)
}
}
return tool
}
@@ -0,0 +1,136 @@
/** Covers fail-closed per-call classification and model-schema isolation. */
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool,
type ToolDefinition,
type ToolExecutionInput,
type ToolExecutionMode,
} from '@deepseek-ai/dsh-tools'
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
return ctx
}
function exec(name: string, args: unknown): ToolExecutionInput {
return { callId: CallId('c1'), name, arguments: args }
}
describe('ToolRegistry.executionMode', () => {
it('returns parallel only for an explicit true classifier', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'safe',
description: 'parallel-safe',
parameters: {},
isConcurrencySafe: () => true,
async execute() { return [] },
}))
expect(ctx.tools.executionMode(exec('safe', {}))).toEqual({ kind: 'parallel' })
})
it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'plain',
description: 'no declaration',
parameters: {},
async execute() { return [] },
}))
expect(ctx.tools.executionMode(exec('plain', {}))).toEqual({ kind: 'exclusive' })
})
it('returns exclusive for an unknown tool', async () => {
const ctx = await setup()
expect(ctx.tools.executionMode(exec('nonexistent', {}))).toEqual({ kind: 'exclusive' })
})
it('returns exclusive when the classifier returns false for these args', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'rw',
description: 'read or write',
parameters: { mode: { type: 'string', required: true } },
isConcurrencySafe: args => args.mode === 'read',
async execute() { return [] },
}))
expect(ctx.tools.executionMode(exec('rw', { mode: 'read' }))).toEqual({ kind: 'parallel' })
expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' })
})
it('classifies invalid defineTool arguments as exclusive without throwing', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'needs-mode',
description: 'requires mode',
parameters: { mode: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute() { return [] },
}))
expect(ctx.tools.executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' })
})
it('treats a throwing raw classifier as exclusive', async () => {
const ctx = await setup()
const raw: ToolDefinition = {
name: 'thrower',
description: 'classifier throws',
parameters: { type: 'object', properties: {} },
isConcurrencySafe() { throw new Error('boom') },
async execute() { return [] },
}
ctx.tools.register(raw)
expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' })
})
it('treats a truthy non-boolean raw result as exclusive', async () => {
const ctx = await setup()
const raw = {
name: 'truthy',
description: 'classifier returns a truthy string',
parameters: { type: 'object', properties: {} },
isConcurrencySafe() { return 'yes' },
async execute() { return [] },
} as unknown as ToolDefinition
ctx.tools.register(raw)
expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' })
})
it('passes parsed arguments directly to a raw definition', async () => {
const ctx = await setup()
let seen: unknown
ctx.tools.register({
name: 'raw-safe',
description: 'raw',
parameters: { type: 'object', properties: {} },
isConcurrencySafe(args) { seen = args; return true },
async execute() { return [] },
})
expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' })
expect(seen).toEqual({ anything: 1 })
})
it('isConcurrencySafe never reaches the model-facing schemas() projection', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'safe',
description: 'parallel-safe',
parameters: { x: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute() { return [] },
}))
const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown>
expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
expect(schema.isConcurrencySafe).toBeUndefined()
})
it('ToolExecutionMode is the object-tagged union', () => {
expectTypeOf<ToolExecutionMode>().toEqualTypeOf<{ kind: 'parallel' } | { kind: 'exclusive' }>()
})
})
+1
View File
@@ -27,6 +27,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
|---|---|---|
| `provider` | (required) | the initial provider route for each per-session agent the bridge creates; ACP model selection may replace it per session |
| `model` | (required) | the initial model for each per-session agent; ACP clients may switch among adapter-advertised models |
| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap shared by the bundled loop's agents; `1` is serial |
| `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
+3
View File
@@ -35,6 +35,8 @@ export interface Config {
provider: string
/** Model name for ACP-created agents (must have a registered adapter). */
model: string
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
maxParallelToolCalls?: number
/** Deployment persona (the system-prompt plugin's `persona` config). */
persona?: string
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
@@ -61,6 +63,7 @@ export interface Config {
export const Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
maxParallelToolCalls: z.number().step(1).min(1),
persona: z.string(),
// The array default is forced to undefined: ABSENT means "lexicographic
// order" (the owning dsh-system-prompt schema does the same), while
@@ -127,6 +127,19 @@ describe('dsh-acp-demo composition', () => {
await ctx.fiber.dispose()
})
it('forwards maxParallelToolCalls to the bundled agent loop', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
maxParallelToolCalls: 3,
persistenceRoot: '/tmp/dsh-acp-demo-test-parallel',
skills: await isolatedSkillsConfig(),
workspaceContext: false,
})
expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
await ctx.fiber.dispose()
})
it('forwards bundled tool config into agent-core', async () => {
const ctx = await mount({
provider: 'mock',
+2 -2
View File
@@ -42,11 +42,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// { agents?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? }
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
## Why a code bundle, not a shared YAML include
@@ -57,6 +57,8 @@ export interface SkillConfig {
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
agents?: AgentLoopConfig['agents']
/** Agent-loop concurrency cap; `1` is serial. */
maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls']
/** The deployment persona (see dsh-system-prompt's `Config`). */
persona?: SystemPromptConfig['persona']
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
@@ -109,6 +111,7 @@ export const Config = z.intersect([
*/
export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'agents'> {
return {
...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {},
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
@@ -160,5 +163,8 @@ export function apply(ctx: Context, config: Config): void {
// rendered order, so workspace instructions must precede the skill catalog.
ctx.plugin(toolSkill, config.skills?.tool ?? {})
ctx.plugin(toolTasks, config.toolTasks ?? {})
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
ctx.plugin(AgentLoop, {
agents: config.agents ?? [],
...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {},
})
}
@@ -147,6 +147,16 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('forwards the global maxParallelToolCalls config to agent-loop', async () => {
const ctx = await mount({
agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock' }],
maxParallelToolCalls: 3,
workspaceContext: false,
})
expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
await ctx.fiber.dispose()
})
it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => {
// ctx.plugin validates + defaults the bundle config first; a direct apply
// skips the schema, so the forwarding `?? []` / `?? ''` are what fire.
+1
View File
@@ -27,6 +27,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
|---|---|---|
| `provider` | (required) | the pre-created `main` agent's registered provider route |
| `model` | (required) | the pre-created `main` agent's model |
| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap shared by the bundled loop's agents; `1` is serial |
| `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
@@ -42,6 +42,8 @@ export interface Config {
provider: string
/** Model name for the `main` agent (must have a registered adapter). */
model: string
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
maxParallelToolCalls?: number
/** Deployment persona (the system-prompt plugin's `persona` config). */
persona?: string
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
@@ -73,6 +75,7 @@ export interface Config {
export const Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
maxParallelToolCalls: z.number().step(1).min(1),
persona: z.string(),
// The array default is forced to undefined: ABSENT means "lexicographic
// order" (the owning dsh-system-prompt schema does the same), while
@@ -164,6 +164,19 @@ describe('dsh-stdio-demo app', () => {
await ctx.fiber.dispose()
})
it('forwards maxParallelToolCalls to the bundled agent loop', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
maxParallelToolCalls: 3,
persistenceRoot: '/tmp/dsh-stdio-demo-spec-parallel',
skills: await isolatedSkillsConfig(),
workspaceContext: false,
})
expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
await ctx.fiber.dispose()
})
it('forwards bundled tool config into agent-core', async () => {
const ctx = await mount({
provider: 'mock',
+2
View File
@@ -46,6 +46,8 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve
`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event.
`read` opts into concurrent scheduling because its only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them.
## Model Experience
+2
View File
@@ -84,6 +84,8 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` },
},
// Observation races fail closed because guarded mutations re-check the version in-lock.
isConcurrencySafe: () => true,
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseReadArgs(args, caps.limit)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
@@ -384,6 +384,33 @@ describe('signal, concurrency, and the fs/observed contract', () => {
expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true)
})
it('a stale observed version from an older read fails closed at edit CAS', async () => {
await writeFile(join(dir, 'a.txt'), 'older content\n')
const target = await ctx.fs.resolve('a.txt')
const firstInfo = await ctx.fs.stat(target)
if (!firstInfo) throw new Error('expected first stat')
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
await writeFile(join(dir, 'a.txt'), 'newer current content\n')
const secondInfo = await ctx.fs.stat(target)
if (!secondInfo) throw new Error('expected second stat')
expect(secondInfo.version).not.toBe(firstInfo.version)
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
// Reproduce an older concurrent read winning the observation race.
ctx.emit('fs/observed', target, firstInfo.version, { agent: { session } })
const edit = await callOwned('edit', {
file_path: 'a.txt',
old_string: 'newer',
new_string: 'edited',
})
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('newer current content\n')
})
it('a throwing fs/observed listener surfaces as isError, but the mutation already hit disk', async () => {
// fs/observed is a plain ctx.emit after the write succeeded; a throwing listener cannot
// roll the write back — it only turns the tool result into isError.
+10
View File
@@ -108,6 +108,16 @@ describe('registration', () => {
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write'])
})
it('declares read parallel-safe while write/edit remain exclusive', async () => {
const { ctx } = await setup()
expect(ctx.tools.executionMode({ callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } }))
.toEqual({ kind: 'parallel' })
expect(ctx.tools.executionMode({ callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } }))
.toEqual({ kind: 'exclusive' })
expect(ctx.tools.executionMode({ callId: CallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } }))
.toEqual({ kind: 'exclusive' })
})
it('registers prompt sections for each tool', async () => {
const { ctx } = await setup()
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
@@ -24,6 +24,10 @@ With `run_in_background: true`, the tool registers the parent-owned task before
| `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. |
| `maxDepth` | Absolute delegation-depth cap; requires `depthLimit` capability. |
## Concurrency
Foreground and background calls are exclusive. Children may share the parent's workspace or external resources, and a unary classifier cannot prove that sibling delegations have disjoint effects. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
## Model Experience
### Tool schema
@@ -97,6 +97,20 @@ describe('dsh-tool-subagent', () => {
expect(foreground.isError).toBe(false)
})
it('keeps foreground and background calls exclusive', async () => {
const ctx = await setup({ provider: 'mock' })
expect(ctx.tools.executionMode({
callId: CallId('subagent-foreground'),
name: 'subagent',
arguments: { description: 'do work', prompt: 'Reply OK' },
})).toEqual({ kind: 'exclusive' })
expect(ctx.tools.executionMode({
callId: CallId('subagent-background'),
name: 'subagent',
arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true },
})).toEqual({ kind: 'exclusive' })
})
it.each([
{ stopReason: 'aborted' as const, fragment: 'cancelled' },
{ stopReason: 'error' as const, fragment: 'failed' },
+2
View File
@@ -11,6 +11,8 @@ Each tool is registered independently; a product that wants only one disables th
| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. |
| `web_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-timeout-policy`), not a model argument. |
Both tools opt into concurrent scheduling because provider reads return content without mutating parent-agent state.
## Config
| Key | Default | Meaning |
+2
View File
@@ -92,6 +92,8 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number): void {
url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' },
},
timeoutMs,
// Provider reads do not mutate parent-agent state.
isConcurrencySafe: () => true,
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseFetchArgs(args)
const result = await ctx.web.fetch(
+2
View File
@@ -109,6 +109,8 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs:
query: { type: 'string', required: true, description: 'The search query.' },
},
timeoutMs,
// Provider reads do not mutate parent-agent state.
isConcurrencySafe: () => true,
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseSearchArgs(args)
const result = await ctx.web.search(
@@ -166,6 +166,10 @@ describe('tool-web registration', () => {
const names = ctx.tools.schemas().map(s => s.name)
expect(names).toContain('web_search')
expect(names).toContain('web_fetch')
expect(ctx.tools.executionMode({ callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } }))
.toEqual({ kind: 'parallel' })
expect(ctx.tools.executionMode({ callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } }))
.toEqual({ kind: 'parallel' })
await fiber.dispose()
expect(ctx.tools.schemas().map(s => s.name)).not.toContain('web_search')
})
+1
View File
@@ -38,6 +38,7 @@ export const LINK_MAP: Record<string, string> = {
TurnEndReason: 'session.md',
ToolDefinition: 'tools.md',
ToolExecution: 'tools.md',
ToolExecutionMode: 'tools.md',
ToolExecutionInput: 'tools.md',
ToolExecutionResult: 'tools.md',
ToolExecutionToken: 'tools.md',
+13 -4
View File
@@ -847,10 +847,19 @@ function renderLifecycle(): string {
` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`,
` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`,
` Driver->>Session: ${mermaidCode('assistant/message')}`,
` Driver->>Session: ${mermaidCode('tool/call')}`,
' Driver->>Tools: execute through pre and post waterfalls',
' Tools-->>Session: tool-owned events when applicable',
` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`,
' Driver->>Tools: classify pending call by executionMode',
' loop barriers and bounded rolling pool, reclassify before start',
' opt call starts',
` Driver->>Session: ${mermaidCode('tool/call')}`,
' Driver->>Tools: ordered pre, concurrent execute',
' Tools-->>Session: tool-owned events when applicable',
' end',
' opt next model-order result ready',
' Driver->>Tools: ordered post',
` Driver->>Session: ${mermaidCode('tool/result')}`,
' end',
' end',
` Driver->>Session: ${mermaidCode('step/end')}`,
` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`,
` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`,
` Driver->>Session: ${mermaidCode('turn/end')}`,
+106 -13
View File
@@ -1,7 +1,7 @@
/**
* Generate `docs/persistence-catalog.md` from every `SessionEventMap` merge and
* the owning `SurfaceEventType` union. This is the durable-record vocabulary,
* not the live Cordis bus. Event declarations must be unique, explicitly typed,
* the owning event-envelope types. This is the durable-record vocabulary, not
* the live Cordis bus. Event declarations must be unique, explicitly typed,
* documented, inheritance-free, and free of Cordis-only `@mode` tags; every
* surface-union member must resolve to one. `--check` verifies the artifact.
*/
@@ -14,13 +14,23 @@ import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/persistence-catalog.md'
/** The fenced-block info string for generated payload blocks (skipped by
* doc-typecheck, since a bare payload fragment is not standalone-compilable). */
/** The fenced-block info string for generated declaration blocks (skipped by
* doc-typecheck, since their imported types are not standalone-compilable). */
const FENCE = 'ts persistence-catalog'
/** The package whose module id plugin merges augment (`declare module '…'`). */
const SESSION_MODULE = '@deepseek-ai/dsh-session'
/** Event-envelope declarations rendered before the per-event vocabulary. */
const EVENT_ENVELOPE_TYPE_NAMES = [
'SessionEventType',
'SurfaceEventType',
'SurfaceOp',
'SessionEvent',
] as const
type EventEnvelopeTypeName = typeof EVENT_ENVELOPE_TYPE_NAMES[number]
/** Primary core-data-structures page for linked payload types. */
const LINK_MAP: Record<string, string> = {
CallId: 'core.md',
@@ -41,6 +51,8 @@ export interface LogEventEntry {
scope: string
/** Payload type text (the member's type annotation, whitespace-collapsed). */
payload: string
/** Source member declaration and complete JSDoc, dedented from its container. */
declaration: string
/** Description prose (the member's JSDoc), one line per paragraph. */
doc: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
@@ -53,6 +65,16 @@ export interface AnnotatedLogEventEntry extends LogEventEntry {
surface: boolean
}
/** One owning event-envelope declaration pasted into the generated catalog. */
export interface EventEnvelopeTypeEntry {
/** Exported declaration name. */
name: EventEnvelopeTypeName
/** Verbatim type declaration, including its complete leading JSDoc. */
declaration: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source: string
}
const printer = ts.createPrinter({ removeComments: true })
/**
@@ -67,6 +89,24 @@ function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
.trim()
}
/**
* Copy a declaration from its leading JSDoc through its closing token while
* removing only the indentation imposed by its containing interface/module.
*/
function declarationText(text: string, sf: ts.SourceFile, node: ts.Node): string {
const raw = rawJsDoc(text, node)
const nodeStart = node.getStart(sf)
const start = raw ? text.lastIndexOf(raw, nodeStart) : nodeStart
const { line } = sf.getLineAndCharacterOfPosition(start)
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
const indent = text.slice(lineStart, start)
return text.slice(lineStart, node.end)
.split('\n')
.map(lineText => lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
.join('\n')
.trimEnd()
}
/**
* Every `interface SessionEventMap` declaration in a source file: the owning
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
@@ -177,7 +217,8 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
if (!doc) {
violations.push(`${where} has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry.`)
}
entries.push({ name, scope: name.split('/')[0] ?? name, payload, doc, source: src })
const declaration = declarationText(text, sf, member)
entries.push({ name, scope: name.split('/')[0] ?? name, payload, declaration, doc, source: src })
}
}
}
@@ -185,6 +226,51 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
return entries
}
/**
* Collect the exported declarations that compose the persisted event envelope,
* preserving their source JSDoc and declaration text.
*/
export function collectEventEnvelopeTypes(scanRoot: string = root): EventEnvelopeTypeEntry[] {
const found = new Map<EventEnvelopeTypeName, EventEnvelopeTypeEntry>()
const violations: string[] = []
const wanted = new Set<string>(EVENT_ENVELOPE_TYPE_NAMES)
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!EVENT_ENVELOPE_TYPE_NAMES.some(name => text.includes(name))) continue
if (packageNameFor(rel, scanRoot) !== SESSION_MODULE) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
for (const stmt of sf.statements) {
if (!ts.isTypeAliasDeclaration(stmt) || !wanted.has(stmt.name.text)) continue
const name = stmt.name.text as EventEnvelopeTypeName
const src = pointer(rel, sf, stmt)
const where = `event-envelope type '${name}' (${src})`
const prior = found.get(name)
if (prior) {
violations.push(`${where} is already declared at ${prior.source}; the persisted envelope type has exactly one owner.`)
continue
}
if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) {
violations.push(`${where} is not exported.`)
}
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, stmt))
if (hasMode) violations.push(`${where} carries an @mode tag, but a persisted type has no dispatch mode.`)
if (!doc) violations.push(`${where} has no description prose. The full JSDoc is part of the generated catalog.`)
found.set(name, { name, declaration: declarationText(text, sf, stmt), source: src })
}
}
const missing = EVENT_ENVELOPE_TYPE_NAMES.filter(name => !found.has(name))
if (missing.length > 0) {
violations.push(`missing event-envelope declaration(s): ${missing.join(', ')}.`)
}
reportViolations('gen-persistence-catalog', violations)
return EVENT_ENVELOPE_TYPE_NAMES.map((name) => {
const entry = found.get(name)
if (!entry) throw new Error(`gen-persistence-catalog: missing checked event-envelope declaration '${name}'.`)
return entry
})
}
/**
* Parse the `SurfaceEventType` union — the surface-eligible subset of event
* types — from source. Hard-errors when the alias is missing, declared more
@@ -246,8 +332,7 @@ function typeLinks(payload: string): string {
/** Render one log event entry. */
function renderEvent(e: AnnotatedLogEventEntry): string[] {
const out = [`#### \`${e.name}\`${e.surface ? 'surface' : 'log-only'}`, '']
if (e.doc) out.push(e.doc, '')
out.push('```' + FENCE, `'${e.name}': ${e.payload}`, '```', '')
out.push('```' + FENCE, e.declaration, '```', '')
const links = typeLinks(e.payload)
if (links) out.push(links, '')
out.push(`Source: [\`${e.source}\`](../${e.source.split(':')[0]})`, '')
@@ -255,18 +340,26 @@ function renderEvent(e: AnnotatedLogEventEntry): string[] {
}
/** Render the full catalog (pure, deterministic given the collected inputs). */
export function render(events: AnnotatedLogEventEntry[]): string {
export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnvelopeTypeEntry[]): string {
const lines: string[] = [
'<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-persistence-catalog` to regenerate. -->',
'',
'# Persistence Log Event Catalog',
'# Session Persistence Event Catalog',
'',
'Every event type that can appear in a session\'s durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](core-data-structures/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
'',
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).',
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).',
'',
'The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
'',
'## Event envelope',
'',
'```' + FENCE,
envelopeTypes.map(entry => entry.declaration).join('\n\n'),
'```',
'',
`Sources: ${envelopeTypes.map(entry => `[\`${entry.source}\`](../${entry.source.split(':')[0]})`).join(' · ')}`,
'',
'## Events',
'',
@@ -285,7 +378,7 @@ export function render(events: AnnotatedLogEventEntry[]): string {
* is stale. Guarded behind an entry-point check so importing this module for
* tests neither regenerates the committed file nor calls process.exit. */
function main(): void {
const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()))
const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()), collectEventEnvelopeTypes())
if (process.argv.includes('--check')) {
let committed: string | null = null
try {
+1
View File
@@ -72,6 +72,7 @@
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionMode", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRunContext", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRestriction", "source": "packages/core/tools/src/index.ts" },
+4 -4
View File
@@ -6,7 +6,7 @@
Concrete ReactLoopAgent factory and driver service.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L391)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L408)
### ctx.agentLoop.create(id, options?, meta?)
@@ -22,7 +22,7 @@ Create an agent and session under one caller-supplied identity, owned by the acc
**Returns** the published running agent.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L522)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L543)
### ctx.agentLoop.createAgent(ownerCtx, options)
@@ -37,7 +37,7 @@ Create an owned agent on a caller-supplied session id.
**Returns** the published handle.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L544)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L565)
### ctx.agentLoop.resume(ownerCtx, options)
@@ -52,4 +52,4 @@ Resume an owned agent from the configured persistence service.
**Returns** the published handle.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L575)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L597)
+1 -1
View File
@@ -228,7 +228,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers
- `sessionId` — exact shared agent/session identity that failed startup.
- `error` — persistence, setup, or publication failure.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L353)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L363)
## approval/*
+21 -7
View File
@@ -6,7 +6,7 @@
Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L378)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L438)
### ctx.tools.register(definition)
@@ -20,7 +20,7 @@ Register globally or in the calling agent scope. Scoped tools shadow globals; du
**Returns** the exact disposer that unregisters the tool.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L468)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L538)
### ctx.tools.restrict(filter)
@@ -34,7 +34,7 @@ Restrict global tools for the calling agent scope. Empty filters, unknown names,
**Returns** the exact disposer that lifts this restriction.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L508)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L578)
### ctx.tools.guard(guard)
@@ -48,7 +48,7 @@ Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A
**Returns** the exact disposer that unregisters the guard.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L559)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L629)
### ctx.tools.get(name, scope?)
@@ -63,7 +63,7 @@ Look up a tool as one scope sees it (scoped shadows global; a restricted-away gl
**Returns** the definition the scope resolves, or undefined when none is visible.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L661)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L731)
### ctx.tools.schemas(scope?)
@@ -77,7 +77,21 @@ Project visible definitions onto the allowlisted model-facing schema fields, exc
**Returns** one deep-cloned schema per visible tool.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L671)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L741)
### ctx.tools.executionMode(exec)
```ts website-api
executionMode(exec: ToolExecutionInput): ToolExecutionMode
```
Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.
- `exec` — call name, parsed arguments, and optional agent scope.
**Returns** the fail-closed scheduling mode.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L762)
### ctx.tools.execute(exec)
@@ -91,4 +105,4 @@ Execute through pre-policy, guards, around-dispatch, post-policy, and final noti
**Returns** the materialized final result.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L694)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L782)