Merge latest origin/master into feat/tui-package

Bring the TUI branch onto master after PR #378 landed so PR #363 is evaluated against the current type-equivalence documentation contract.

Resolve the bilingual development-record overlap by regenerating the English/Chinese consistency record from the merged documents. This preserves the branch's built-subprocess guidance alongside master's expanded JSDoc type-equivalence rules.
This commit is contained in:
Tianyi Cui
2026-07-19 12:47:05 +08:00
29 changed files with 1068 additions and 282 deletions

View File

@@ -27,7 +27,7 @@ Placement: bugs → postmortems; rationale → RFCs; procedures → cookbooks; t
- **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, RFCs, or postmortems. - **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, RFCs, or postmortems.
- **Write an RFC in the same PR for decisions a maintainer may reasonably revisit.** Mechanical or self-evident changes need none ([when to write one](rfc/README.md)). - **Write an RFC in the same PR for decisions a maintainer may reasonably revisit.** Mechanical or self-evident changes need none ([when to write one](rfc/README.md)).
- **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit.
- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type definition is fenced ` ```ts type-equiv ` and registered in the manifest so it cannot drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc are fenced ` ```ts type-equiv ` and registered in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)).
- **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)). - **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)).
- **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). - **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)).
- **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage, decision rules, and examples. - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage, decision rules, and examples.

View File

@@ -9,12 +9,20 @@ Source: [`packages/ui/user-approval/src/index.ts`](../../packages/ui/user-approv
Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call or agent/session ids. Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call or agent/session ids.
```ts type-equiv ```ts type-equiv
/**
* Pairs one `approval/asked` audit event with its `approval/decided`.
* Service-issued (one fresh id per {@link ApprovalService.request} call).
*/
type ApprovalRequestId = Branded<'ApprovalRequestId'> type ApprovalRequestId = Branded<'ApprovalRequestId'>
``` ```
`ApprovalOutcome` is closed and fail-closed. `allowed-once` grants only the asked-about action; callers deny on `rejected`, `cancelled`, and `unavailable`. A missing, non-owning, throwing, or non-conforming answerer becomes `unavailable` rather than opening the gate. `ApprovalOutcome` is closed and fail-closed. `allowed-once` grants only the asked-about action; callers deny on `rejected`, `cancelled`, and `unavailable`. A missing, non-owning, throwing, or non-conforming answerer becomes `unavailable` rather than opening the gate.
```ts type-equiv ```ts type-equiv
/**
* Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn
* request, or unavailable answerer. Callers fail closed on `unavailable`.
*/
type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
``` ```
@@ -23,6 +31,18 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override. `ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override.
```ts type-equiv ```ts type-equiv
/**
* A session's approval policy — what happens to an {@link ApprovalService}
* ask BEFORE any interactive answerer sees it:
*
* - `'ask'` (the default) — delegate to the composed answerers; with none
* composed the chain falls through to the fail-closed `'unavailable'`
* (exactly today's behavior).
* - `'never'` — never prompt anyone: every ask resolves `'rejected'`
* deterministically. The strict headless stance (CI, unattended runs) and
* the only policy value stated in the system prompt — unlike `'ask'`, its
* outcome is knowable without asking, so stating it cannot overclaim.
*/
type ApprovalPolicy = 'ask' | 'never' type ApprovalPolicy = 'ask' | 'never'
``` ```
@@ -33,6 +53,10 @@ The prompt section states the deterministic `never` behavior and records either
`ApprovalRequest` identifies the agent and tool action closely enough to route and audit the question. It deliberately omits tool arguments: an answerer attaches the prompt to the already-streamed tool call through `callId` instead of rendering a second copy that could drift. `ApprovalRequest` identifies the agent and tool action closely enough to route and audit the question. It deliberately omits tool arguments: an answerer attaches the prompt to the already-streamed tool call through `callId` instead of rendering a second copy that could drift.
```ts type-equiv ```ts type-equiv
/**
* Readonly same-process permission question. `callId` links to an already
* presented tool call, so arguments are not duplicated here.
*/
interface ApprovalRequest { interface ApprovalRequest {
/** /**
* The agent on whose behalf the question is asked. Routes the question (a * The agent on whose behalf the question is asked. Routes the question (a

View File

@@ -9,10 +9,12 @@ Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.t
`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot. `DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot.
```ts type-equiv ```ts type-equiv
/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
``` ```
```ts type-equiv ```ts type-equiv
/** Trusted DeepSeek Harness variables for one bash execution. */
type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>> type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
``` ```
@@ -21,6 +23,12 @@ type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`/`stdoutMaxBytes`, filled from config or request policy) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory or output budget came from. The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`/`stdoutMaxBytes`, filled from config or request policy) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory or output budget came from.
```ts type-equiv ```ts type-equiv
/**
* A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and
* filled by {@link BashExecutor.resolve} from the implementation's config.
* This is the model-/plugin-facing shape; pass it to `resolve()` to obtain a
* fully-resolved {@link BashExecSpec}.
*/
interface BashExecRequest { interface BashExecRequest {
command: string command: string
/** Working directory override (default: implementation-configured). */ /** Working directory override (default: implementation-configured). */
@@ -59,24 +67,17 @@ interface BashExecRequest {
* reject non-`DSH_*` names supplied through this managed channel. * reject non-`DSH_*` names supplied through this managed channel.
*/ */
dshEnv?: DshEnvironment | undefined dshEnv?: DshEnvironment | undefined
/** /** Explicit per-call sandbox mode override. */
* Explicit per-call sandbox-policy input, overriding the executor's
* configured default mode for THIS call. Never a silent default: a
* consumer sets it only from an explicit policy source — an
* `'allowed-once'` grant a human just issued through `ctx.approval` (the
* escalation flow in the sandbox RFC § Escalation, which outranks), or the
* session's standing override folded from its own `bash/sandbox-mode`
* events (the sandbox RFC § Per-session mode switching — the user's recorded per-session
* choice). A sandboxing executor confines THIS call under the given mode;
* a non-sandboxing executor carries the field and confines nothing (the
* tool layer stamps neither escalation nor overrides without a sandboxing
* executor — see {@link BashExecutor.sandboxMode}).
*/
sandboxMode?: SandboxMode | undefined sandboxMode?: SandboxMode | undefined
} }
``` ```
```ts type-equiv ```ts type-equiv
/**
* A resolved execution spec. {@link BashExecutor.resolve} fills and caps the
* required fields; {@link BashExecutor.start} ignores `timeoutMs` because
* background processes have no executor timeout.
*/
interface BashExecSpec { interface BashExecSpec {
command: string command: string
workdir: string workdir: string
@@ -88,31 +89,18 @@ interface BashExecSpec {
stdoutMaxBytes: number stdoutMaxBytes: number
/** Abort signal — implementations kill the command when it fires. */ /** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined signal?: AbortSignal | undefined
/** /** Bytes to write to stdin before closing it; absent means no stdin. */
* Bytes to write to the command's stdin (then close it), carried through
* verbatim from {@link BashExecRequest.stdin}. It has no config default, so
* a missing value means "no stdin" and remains an ordinary optional.
*/
stdin?: string | undefined stdin?: string | undefined
/** /**
* Extra environment entries, carried through verbatim from * Ordinary environment entries carried through from
* {@link BashExecRequest.env} and merged by the implementation AFTER its * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}.
* credential scrub (an explicit entry wins even when its name matches the * OPTIONAL on the spec for the same reason as `stdin`: absent means no
* scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no * ordinary extra environment.
* config default, absent means "no extra env".
*/ */
env?: Record<string, string> | undefined env?: Record<string, string> | undefined
/** Managed `DSH_*` snapshot; implementations reject ordinary names. */ /** Managed `DSH_*` snapshot; implementations reject ordinary names. */
dshEnv?: DshEnvironment | undefined dshEnv?: DshEnvironment | undefined
/** /** Resolved sandbox mode; ignored by executors that do not confine. */
* The sandbox mode this call executes under, required-but-nullable so every
* resolved spec states its policy. A sandboxing executor's `resolve()` stamps
* the effective mode (the request's explicit override, else its configured
* default) so `run()`/`start()` read the spec, never the config;
* a non-sandboxing executor carries the request value through verbatim and
* ignores it (`undefined` under such an executor means what its README says:
* unconfined execution).
*/
sandboxMode: SandboxMode | undefined sandboxMode: SandboxMode | undefined
} }
``` ```
@@ -126,24 +114,31 @@ interface BashExecSpec {
The outcome of one completed (or killed) foreground run. Orthogonal outcomes are reported **independently** — a process can both time out AND exit 0 because it trapped the signal — so `timedOut`, `aborted`, `signal`, and `exitCode` are each their own field; a caller never reads a cut-short run as a clean success. The outcome of one completed (or killed) foreground run. Orthogonal outcomes are reported **independently** — a process can both time out AND exit 0 because it trapped the signal — so `timedOut`, `aborted`, `signal`, and `exitCode` are each their own field; a caller never reads a cut-short run as a clean success.
```ts type-equiv ```ts type-equiv
/** The outcome of one completed (or killed) foreground run. */
interface BashRunResult { interface BashRunResult {
/** Exit code; null when the process died from a signal. */ /** Exit code; null when the process died from a signal. */
exitCode: number | null exitCode: number | null
/** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */
signal: NodeJS.Signals | null signal: NodeJS.Signals | null
/** True when the executor's own timeout killed the command. */ /**
* True when the executor's own timeout was the FIRST cause to cut the command
* short. Mutually exclusive with {@link aborted}: one fused deadline drives
* both the timeout and the caller's cancellation, so a timeout and an abort
* racing before process close report the single first-abort cause, not both
* (see the [timeout-library RFC](../../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
*/
timedOut: boolean timedOut: boolean
/** True when the caller's AbortSignal killed the command. */ /**
* True when the caller's `AbortSignal` was the FIRST cause to kill the command
* (and it was not the executor's own timeout). Mutually exclusive with
* {@link timedOut} — see there for the first-cause classification.
*/
aborted: boolean aborted: boolean
/** The effective timeout applied to this run (after defaulting/capping). */ /** The effective timeout applied to this run (after defaulting/capping). */
timeoutMs: number timeoutMs: number
stdout: CollectedOutput stdout: CollectedOutput
stderr: CollectedOutput stderr: CollectedOutput
/** /** Sandbox execution facts, absent for an unsandboxed executor. */
* Sandbox facts, present iff a sandboxing executor ran the command — an
* unsandboxed executor (e.g. `dsh-bash-local`) never sets it. See
* {@link BashSandboxInfo} for the `denied` classification semantics.
*/
sandbox?: BashSandboxInfo sandbox?: BashSandboxInfo
} }
``` ```
@@ -151,6 +146,7 @@ interface BashRunResult {
Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info. When truncated, `text` is the **tail** and the complete stream spills to a private file: Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info. When truncated, `text` is the **tail** and the complete stream spills to a private file:
```ts type-equiv ```ts type-equiv
/** One captured stream: the (possibly truncated) text plus recovery info. */
interface CollectedOutput { interface CollectedOutput {
/** Collected text — the TAIL of the stream when truncated. */ /** Collected text — the TAIL of the stream when truncated. */
text: string text: string
@@ -168,36 +164,19 @@ A sandbox-consuming executor exposes its configured fallback through `BashExecut
A sandboxed run reports its mode, conservative denial classification, and enforcement completeness. `runnerFailed` marks a sandbox runner failure before the command ran; foreground execution throws `SANDBOX_UNAVAILABLE`, while a settled background process has only its facts channel. A sandboxed run reports its mode, conservative denial classification, and enforcement completeness. `runnerFailed` marks a sandbox runner failure before the command ran; foreground execution throws `SANDBOX_UNAVAILABLE`, while a settled background process has only its facts channel.
```ts type-equiv ```ts type-equiv
/**
* Sandbox facts for one run, present iff a sandboxing executor handled it.
* Facts are reported independently of process exit status so callers can
* distinguish command failures from policy denials and runner failures.
*/
interface BashSandboxInfo { interface BashSandboxInfo {
/** The mode the command actually ran under. */ /** The mode the command actually ran under. */
mode: SandboxMode mode: SandboxMode
/** /** Whether the sandbox denied a file operation. */
* True when the executor classifies this run's failure as the sandbox
* denying a file operation. The classification is CONSERVATIVE (a failed
* exit whose stderr carries a filesystem-permission signature) and reads
* the COLLECTED stderr — the bounded in-memory tail per
* {@link CollectedOutput} semantics, so a signature that survives only in a
* spill file is missed toward `denied: false`. A plain command failure
* keeps `denied: false` even under a sandboxed mode.
*/
denied: boolean denied: boolean
/** /** How completely the selected runner enforced the requested mode. */
* How completely the runner enforced `mode`'s file effects — see
* {@link SandboxEnforcement}. Absent exactly when `mode` is
* `danger-full-access`: nothing is confined, so there is no enforcement to
* report.
*/
enforcement?: SandboxEnforcement enforcement?: SandboxEnforcement
/** /** Whether the sandbox runner failed before the command could run. */
* True when the executor classifies this failure as the SANDBOX RUNNER
* itself failing (missing binary, refused profile, fail-closed refusal
* before exec) — the command NEVER RAN; this is a sandbox failure, not a
* task failure, and it outranks `denied` (a runner's own error text can
* contain denial words). Only ever stamped on settled BACKGROUND tasks: a
* foreground run surfaces the same condition as the thrown
* `SANDBOX_UNAVAILABLE` error instead (the foreground path has an error
* channel; a settled task's facts are its only channel).
*/
runnerFailed?: boolean runnerFailed?: boolean
} }
``` ```
@@ -209,6 +188,11 @@ One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (o
`start()` returns a handle with no id or owner. `dsh-tool-bash` adapts it into `ctx.tasks.start()` hooks; the generic runtime then owns task identity and lifecycle. `done` resolves when the process closes and never rejects, reads remain valid after settlement, and sandbox facts are stamped before `done` resolves. `start()` returns a handle with no id or owner. `dsh-tool-bash` adapts it into `ctx.tasks.start()` hooks; the generic runtime then owns task identity and lifecycle. `done` resolves when the process closes and never rejects, reads remain valid after settlement, and sandbox facts are stamped before `done` resolves.
```ts type-equiv ```ts type-equiv
/**
* A background process handle returned by {@link BashExecutor.start}. It is the
* only access path; buffered output remains readable after exit. Executor
* disposal kills running processes and awaits {@link done}.
*/
interface BashProcess { interface BashProcess {
/** Process lifecycle state (settled exactly once). */ /** Process lifecycle state (settled exactly once). */
status: BashProcessStatus status: BashProcessStatus
@@ -237,6 +221,7 @@ interface BashProcess {
`readOutput()` returns the incremental delta and spill recovery facts: `readOutput()` returns the incremental delta and spill recovery facts:
```ts type-equiv ```ts type-equiv
/** One incremental {@link BashProcess.readOutput} read. */
interface BashProcessRead { interface BashProcessRead {
/** Output produced since the previous read (stderr in a marked section). */ /** Output produced since the previous read (stderr in a marked section). */
delta: string delta: string

View File

@@ -9,6 +9,12 @@ Source: [`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-
A `CodeRunRequest` carries **everything the runtime acts on** — per the "explicit > implicit at package seams" rule, defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`: A `CodeRunRequest` carries **everything the runtime acts on** — per the "explicit > implicit at package seams" rule, defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`:
```ts type-equiv ```ts type-equiv
/**
* One run: the program source plus everything the runtime acts on. Per the
* explicit-over-implicit convention, defaulting (time budgets, output caps)
* is the implementation's validated config — a request carries no optional
* tuning knobs for a hidden `??` to fill in.
*/
interface CodeRunRequest { interface CodeRunRequest {
/** /**
* The program source, in the runtime's {@link ../index.ts | language}. It * The program source, in the runtime's {@link ../index.ts | language}. It
@@ -31,6 +37,11 @@ interface CodeRunRequest {
The result reports an error as a **field**, never a rejection of `run()` — reporting a failed program is the caller's job, not an exception path (mirroring `BashExecutor.run`'s resolve-on-failure contract): The result reports an error as a **field**, never a rejection of `run()` — reporting a failed program is the caller's job, not an exception path (mirroring `BashExecutor.run`'s resolve-on-failure contract):
```ts type-equiv ```ts type-equiv
/**
* The outcome of one run. An error is a FIELD on a resolved result, never a
* rejection of `run()` — reporting a failed program is the caller's job, not
* an exception path.
*/
interface CodeRunResult { interface CodeRunResult {
/** /**
* The program's completion value (its top-level `return`), when it ran to * The program's completion value (its top-level `return`), when it ran to
@@ -51,6 +62,13 @@ interface CodeRunResult {
Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the Code Mode consumer passes one: `tools`). Arguments and resolutions must be structured-cloneable — a runtime may bridge calls across a serialization boundary — and a runtime treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision): Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the Code Mode consumer passes one: `tools`). Arguments and resolutions must be structured-cloneable — a runtime may bridge calls across a serialization boundary — and a runtime treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision):
```ts type-equiv ```ts type-equiv
/**
* A named group of {@link CodeBindingFunction}s the runtime exposes to the
* program as one global object (e.g. `tools`). Function names are arbitrary
* strings — a runtime must treat names like `__proto__` or `constructor` as
* ordinary own properties (null-prototype construction), never as prototype
* collisions.
*/
interface CodeBindingNamespace { interface CodeBindingNamespace {
/** The global identifier the program sees (must be a valid JS identifier). */ /** The global identifier the program sees (must be a valid JS identifier). */
global: string global: string
@@ -60,6 +78,14 @@ interface CodeBindingNamespace {
``` ```
```ts type-equiv ```ts type-equiv
/**
* One host-side function exposed to the program as an async callable. The
* runtime bridges calls to it (possibly across a serialization boundary), so
* `args` and the resolution value MUST be structured-cloneable; a runtime
* rejects a non-cloneable value with a descriptive error rather than
* corrupting the run. A rejection of this function surfaces inside the
* program as a rejection of the corresponding call.
*/
type CodeBindingFunction = (args: unknown) => Promise<unknown> type CodeBindingFunction = (args: unknown) => Promise<unknown>
``` ```
@@ -70,6 +96,16 @@ Logs are plain strings in emission order. The runtime captures the program's con
Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither: Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither:
```ts type-equiv ```ts type-equiv
/**
* Why a run failed. The kinds are orthogonal outcomes reported independently
* (per docs/defensive-patterns.md): a budget expiry is not an exception, an
* abort is not a timeout, and a substrate death is neither.
*
* - `'exception'` — the program threw or failed to parse/transform.
* - `'timeout'` — an implementation-owned budget expired; the message says which.
* - `'abort'` — {@link CodeRunRequest.signal} fired.
* - `'worker-exit'` — the execution substrate died without settling (e.g. OOM).
*/
interface CodeRunFailure { interface CodeRunFailure {
/** The failure class (see the interface doc for each kind's meaning). */ /** The failure class (see the interface doc for each kind's meaning). */
kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'

View File

@@ -23,6 +23,7 @@ These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` b
What a successful compaction returns to its caller: the bookkeeping-event seqs, raw summary, shadowed range and seqs, and estimated token count. What a successful compaction returns to its caller: the bookkeeping-event seqs, raw summary, shadowed range and seqs, and estimated token count.
```ts type-equiv ```ts type-equiv
/** Result of a successful compaction operation. */
interface CompactionResult { interface CompactionResult {
/** The seq of the appended `compact/start` event. */ /** The seq of the appended `compact/start` event. */
startSeq: number startSeq: number

View File

@@ -36,7 +36,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` |
| [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality |
> Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. > Type declarations and their JSDoc on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)).
FIXME(catalog-verbs): the drift gate covers only the nouns (the pasted type shapes); every method surface on these pages is hand-written prose. core-data-structures should probably also generate the *verbs* — the public methods of the cataloged classes — so a signature change cannot silently outdate the catalog. FIXME(catalog-verbs): the drift gate covers only the nouns (the pasted type shapes); every method surface on these pages is hand-written prose. core-data-structures should probably also generate the *verbs* — the public methods of the cataloged classes — so a signature change cannot silently outdate the catalog.
@@ -83,6 +83,7 @@ The `Branded<B>` primitive lives in its own type-only package, [dsh-brand](../..
Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts) Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts)
```ts type-equiv ```ts type-equiv
/** A string carrying a compile-time-only brand `B`. */
type Branded<B extends string> = string & { readonly [BRAND]: B } type Branded<B extends string> = string & { readonly [BRAND]: B }
``` ```
@@ -95,6 +96,10 @@ A conversation is `Message`s; a message is an array of typed **content blocks**.
Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts)
```ts type-equiv ```ts type-equiv
/**
* Merge-extensible content blocks keyed by `type`. New core blocks must land
* with adapter, UI, and compaction support.
*/
interface ContentBlockMap { interface ContentBlockMap {
'text': TextBlock 'text': TextBlock
'reasoning': ReasoningBlock 'reasoning': ReasoningBlock
@@ -108,6 +113,7 @@ The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBl
A `Message` is a role plus blocks. Loop-derived assistant messages carry their durable provider/model identity and optional adapter-private replay metadata: A `Message` is a role plus blocks. Loop-derived assistant messages carry their durable provider/model identity and optional adapter-private replay metadata:
```ts type-equiv ```ts type-equiv
/** Provider ownership and adapter-private replay data for an assistant message. */
interface AssistantProvenance { interface AssistantProvenance {
/** Provider route that produced the message. */ /** Provider route that produced the message. */
provider: string provider: string
@@ -123,6 +129,10 @@ interface AssistantProvenance {
``` ```
```ts type-equiv ```ts type-equiv
/**
* A single message in a conversation history. Loop-derived assistant messages
* always carry provenance; callers may omit it on hand-built foreign history.
*/
interface Message { interface Message {
role: 'system' | 'user' | 'assistant' role: 'system' | 'user' | 'assistant'
content: ContentBlock[] content: ContentBlock[]
@@ -134,6 +144,10 @@ interface Message {
Where a message came from is itself a merge-extensible sum type: Where a message came from is itself a merge-extensible sum type:
```ts type-equiv ```ts type-equiv
/**
* Where a message (or injected content) came from.
* Merge-extensible sum type — plugins add their own `kind`s.
*/
interface MessageSourceMap { interface MessageSourceMap {
user: { kind: 'user' } user: { kind: 'user' }
plugin: { kind: 'plugin'; plugin: string } plugin: { kind: 'plugin'; plugin: string }
@@ -155,6 +169,7 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts)
Provider and model discovery uses small provider-neutral descriptors. A model catalog is advisory: routing still keys on a registered provider, and an adapter may accept unlisted model ids. Provider and model discovery uses small provider-neutral descriptors. A model catalog is advisory: routing still keys on a registered provider, and an adapter may accept unlisted model ids.
```ts type-equiv ```ts type-equiv
/** Display metadata for one registered provider route. */
interface LlmProviderInfo { interface LlmProviderInfo {
/** Provider route key used by {@link GenerateOptions.provider}. */ /** Provider route key used by {@link GenerateOptions.provider}. */
id: string id: string
@@ -164,6 +179,7 @@ interface LlmProviderInfo {
``` ```
```ts type-equiv ```ts type-equiv
/** One adapter-discovered model; catalog membership is advisory, not request validation. */
interface LlmModelInfo { interface LlmModelInfo {
/** Provider route that owns this model entry. */ /** Provider route that owns this model entry. */
provider: string provider: string
@@ -177,6 +193,7 @@ interface LlmModelInfo {
``` ```
```ts type-equiv ```ts type-equiv
/** A single model request, fully assembled. */
interface GenerateOptions { interface GenerateOptions {
/** Registered provider route selecting the adapter instance. */ /** Registered provider route selecting the adapter instance. */
provider: string provider: string
@@ -212,6 +229,10 @@ interface GenerateOptions {
Why a model response stopped is a merge-extensible reason: Why a model response stopped is a merge-extensible reason:
```ts type-equiv ```ts type-equiv
/**
* Why a model response stopped.
* Merge-extensible so adapters can surface provider-specific reasons.
*/
interface FinishReasonMap { interface FinishReasonMap {
'stop': { kind: 'stop' } 'stop': { kind: 'stop' }
'tool-calls': { kind: 'tool-calls' } 'tool-calls': { kind: 'tool-calls' }
@@ -226,6 +247,13 @@ interface FinishReasonMap {
`GenerateOptions.tools` carries `ToolSchema` — the JSON-schema description of a tool, as sent to the model. It is declared in dsh-llm (not dsh-tools) precisely because it is part of the request the loop assembles every step: `GenerateOptions.tools` carries `ToolSchema` — the JSON-schema description of a tool, as sent to the model. It is declared in dsh-llm (not dsh-tools) precisely because it is part of the request the loop assembles every step:
```ts type-equiv ```ts type-equiv
/**
* JSON-schema description of a tool, as sent to the model.
*
* Declared here (not in dsh-tools) because it is part of {@link GenerateOptions};
* dsh-tools' ToolDefinition and dsh-system-prompt's PromptAssembly both import
* it from this package.
*/
interface ToolSchema { interface ToolSchema {
name: string name: string
description: string description: string
@@ -247,6 +275,11 @@ On the wire, a loop-built request reads in this order: the `system` slot (the re
FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them.
```ts type-equiv ```ts type-equiv
/**
* Provider + model + sampling scalars of one conversation's requests. Every field maps
* 1:1 onto the same-named `GenerateOptions` field; the loop builds requests
* from the logged header rather than accepting these per call.
*/
interface LlmCallConfig { interface LlmCallConfig {
provider: string provider: string
model: string model: string
@@ -263,6 +296,19 @@ A `Session` is an **append-only log** of typed `SessionEvent`s — the single so
Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts)
```ts type-equiv ```ts type-equiv
/**
* 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.
*/
type SessionEvent<T extends SessionEventType = SessionEventType> = { type SessionEvent<T extends SessionEventType = SessionEventType> = {
[K in SessionEventType]: { [K in SessionEventType]: {
type: K type: K
@@ -275,7 +321,9 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
/** /**
* Seq numbers of events that are provenance sources of this event * Seq numbers of events that are provenance sources of this event
* (e.g. the `assistant/chunk` seqs that built an `assistant/message`, * (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
* or the surface nodes shadowed by a compaction replace node). * 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[] sourceEventSeqs?: number[]
/** How this event entered the surface; absent for non-surface events. */ /** How this event entered the surface; absent for non-surface events. */
@@ -295,35 +343,29 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types
`InjectOptions` extends ordinary message attribution with context-only framing and durable model-hidden JSON metadata: `InjectOptions` extends ordinary message attribution with context-only framing and durable model-hidden JSON metadata:
```ts type-equiv ```ts type-equiv
/** Options specific to durable synthetic context injection. */
interface InjectOptions extends SendOptions { interface InjectOptions extends SendOptions {
/** Keep the canonical context tag, or send caller-owned framing verbatim. */
envelope?: ContextEnvelope envelope?: ContextEnvelope
/** Opaque JSON state retained in the session event but hidden from the model. */
meta?: JsonValue meta?: JsonValue
} }
``` ```
```ts type-equiv ```ts type-equiv
/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */
interface Agent { interface Agent {
/** The single identity shared with {@link session}. */
readonly id: SessionId readonly id: SessionId
readonly options: AgentOptions readonly options: AgentOptions
readonly session: Session readonly session: Session
readonly status: AgentStatus readonly status: AgentStatus
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
/**
* The agent's scope context (`@deepseek-ai/dsh-scope`, key = this agent):
* registrations through it — tools, prompt sections/variables, listeners,
* restrictions — are visible to this agent only and unwind when it is
* disposed; `agent.ctx.on('agent/…')` listeners fire only for this agent.
*/
readonly ctx: Context readonly ctx: Context
/** /**
* Queue a user message. Starts a turn when idle; otherwise waits for the next * Queue detached, frozen lossless-JSON input; starts a turn when idle.
* turn. Content and the resolved source are accepted as one detached, * Invalid input throws synchronously before notification or enqueue.
* deeply-frozen lossless-JSON record before notification or enqueue, so
* caller or `agent/queued` listener in-place mutation cannot change later
* log/model input. Throws synchronously when either value is not losslessly
* JSON-serializable; `agent/prompt-submit` may still return an explicit
* replacement.
*/ */
send(content: ContentBlock[], options?: SendOptions): void send(content: ContentBlock[], options?: SendOptions): void
@@ -335,77 +377,25 @@ interface Agent {
steer(content: ContentBlock[], options?: SendOptions): void steer(content: ContentBlock[], options?: SendOptions): void
/** /**
* Inject in-session context (file-change notices, skill content, cron * Append detached model-facing context without running the model. An open-turn
* notifications, …): appends a `context/message` session event the next model * injection joins at the current log position unless the current tool batch is
* request sees at its chronological position, rendered as synthetic context * executing; then it waits FIFO until that batch settles and drains before turn
* rather than a user prompt. The default uses the canonical context tag; * close even when interrupted. Idle injection uses a one-shot turn and durability
* `options.envelope: 'raw'` preserves caller-owned framing. Does not run the * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`.
* model.
*
* In an open turn, inject appends at the current log position except while
* the current tool-call batch executes: accepted context waits FIFO until the
* batch settles, then appends after every recorded result and before turn
* close even when execution is interrupted.
*
* Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn;
* an inject while idle wraps its `context/message` in a one-shot `injection`
* turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for
* durability, so every event stays inside a turn and a persistence backend
* never loses a between-turn notice. The idle checkpoint is fire-and-forget
* (inject is synchronous): a failing flush is reported via `agent/error`
* (step `0`) and the logger, never thrown into the caller.
*
* Live-adapter review has validated the canonical tagged-envelope rendering
* against current DeepSeek behavior; provider-specific mismatches belong in
* that adapter, not in the canonical session vocabulary.
*/ */
inject(content: ContentBlock[], options?: InjectOptions): void inject(content: ContentBlock[], options?: InjectOptions): void
/** /**
* Cancel ALL pending work for the agent. `cancel()`: * Clear queued and steering work, including work waiting to start, and abort
* * the active step. The supplied reason is preserved across pre-step and active
* - clears the queued FIFO (un-started prompts never run) and the steering * cancellation windows, and `whenIdle()` resolves after cancellation reaches
* FIFO (steering for the cancelled turn is dropped, not re-enqueued); * quiescence. Idle cancellation is a no-op and does not arm a later cancel.
* - aborts the in-flight step if one is running (the turn ends `aborted`);
* - drops a turn that is about to start (a `cancel()` landing in the
* pre-step window — after a `send()` queued but before the loop flips to
* `running`, or after `running` is emitted but before the first step) so
* that queued prompt does not run and cannot be batched into the cancelled
* turn.
*
* After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state.
* `cancel()` on an idle agent with nothing queued or running is a safe no-op
* — it does NOT arm anything that would drop a later legitimate prompt.
*/ */
cancel(reason?: string): void cancel(reason?: string): void
/** /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
* Resolve once the agent has reached quiescence after settling out of
* `running`, or immediately if it is already idle with no queued work. A
* non-owner's quiescence-observation hook: a consumer that does NOT own the
* agent's lifecycle awaits this to proceed only after queued/running work has
* fully stopped, rather than returning while the driver is still streaming or
* about to start a queued turn — without itself tearing the agent down. (A
* lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the
* loop-exit promise directly as part of stopping and unregistering. So this is
* for a non-owning observer — e.g. a test awaiting a turn to settle, or a
* monitor — that wants the settle signal but must not dispose the agent.)
*
* "Quiescence", not merely "status changed": a disposed agent emits
* `agent/status('disposed')` from inside its disposer, BEFORE the driver loop
* has unwound — so `whenIdle()` resolving on `disposed` must wait for the loop
* to actually exit (the implementation chains the loop-exit promise), not just
* observe the status flip. A mid-step disposal that never reaches `idle` still
* unblocks the await this way.
*/
whenIdle(): Promise<void> whenIdle(): Promise<void>
// Subagent delegation is realized on top of this interface by the
// `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates
// the child through `ctx.agents.create` (fork seeds the child Session with a
// balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn
// starts fresh) and drives it as an ordinary Agent handle, so steer() and
// event subscription work uniformly. See docs/core-data-structures/subagent.md.
} }
``` ```
@@ -420,10 +410,13 @@ Each `agent/*` interception waterfall returns a small, seam-specific typed union
Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
```ts type-equiv ```ts type-equiv
/** Model-facing context injected by a listener; `source` prevents plugin text from being labeled as user input. */
interface HookContext { interface HookContext {
content: ContentBlock[] content: ContentBlock[]
source: MessageSource source: MessageSource
/** Keep the canonical context tag, or use caller-owned framing verbatim. */
envelope?: ContextEnvelope envelope?: ContextEnvelope
/** Opaque JSON state retained in the session event but hidden from the model. */
meta?: JsonValue meta?: JsonValue
} }
``` ```
@@ -431,6 +424,11 @@ interface HookContext {
`agent/prompt-submit` returns a `PromptDecision` (allow a drained queued message — optionally rewriting its `content` or attaching `additionalContexts` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`): `agent/prompt-submit` returns a `PromptDecision` (allow a drained queued message — optionally rewriting its `content` or attaching `additionalContexts` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`):
```ts type-equiv ```ts type-equiv
/**
* Prompt interception result. `allow.content` replaces the prompt and each
* `additionalContexts` entry becomes a separate context message. `block` records a
* durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn.
*/
type PromptDecision = type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
| { kind: 'block'; reason: string } | { kind: 'block'; reason: string }
@@ -439,6 +437,7 @@ type PromptDecision =
`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no context envelope or metadata — the typed `/goal` pattern): `agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no context envelope or metadata — the typed `/goal` pattern):
```ts type-equiv ```ts type-equiv
/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */
type ContinuationDecision = type ContinuationDecision =
| { action: 'stop' } | { action: 'stop' }
| { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } }
@@ -447,12 +446,18 @@ type ContinuationDecision =
`agent/turn-stop` returns the stop-only `ContinuationStop` subset or `undefined`. The loop calls this serial checkpoint after folding the ordinary decision, its reason, and pending steering; a stop is terminal and discards pending steering. `agent/turn-stop` returns the stop-only `ContinuationStop` subset or `undefined`. The loop calls this serial checkpoint after folding the ordinary decision, its reason, and pending steering; a stop is terminal and discards pending steering.
```ts type-equiv ```ts type-equiv
/**
* The terminal subset of {@link ContinuationDecision}. A listener on
* `agent/turn-stop` returns this to make the already-composed continuation
* outcome terminal; `undefined` abstains.
*/
type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }> type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
``` ```
`agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it): `agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it):
```ts type-equiv ```ts type-equiv
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
``` ```

View File

@@ -11,8 +11,17 @@ Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.
Every operation resolves a user-supplied path to an opaque backend target first. Consumers may display `displayPath`, but must not parse `targetKey` (a branded opaque id) or assume it is a local absolute path. Every operation resolves a user-supplied path to an opaque backend target first. Consumers may display `displayPath`, but must not parse `targetKey` (a branded opaque id) or assume it is a local absolute path.
```ts type-equiv ```ts type-equiv
/**
* A path resolved by a backend into a stable identity. `resolve()` produces
* this; every other operation takes it.
*/
interface FsTarget { interface FsTarget {
/** Opaque key for stale guards and target lookup. */
targetKey: FsTargetKey targetKey: FsTargetKey
/**
* Path for model/UI-facing output. May be a local absolute path,
* workspace-relative path, or remote URI depending on the backend.
*/
displayPath: string displayPath: string
} }
``` ```
@@ -20,19 +29,40 @@ interface FsTarget {
The backend owns file-version tokens — the freshness token a write/edit guards against. The policy plugin stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings. The backend owns file-version tokens — the freshness token a write/edit guards against. The policy plugin stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings.
```ts type-equiv ```ts type-equiv
/**
* Opaque key for stale guards and target lookup. The local backend uses a
* realpath-like string; a remote backend might use a workspace URI or file id.
* Consumers MUST NOT parse it or assume it is a local absolute path.
*/
type FsTargetKey = Branded<'FsTargetKey'> type FsTargetKey = Branded<'FsTargetKey'>
``` ```
```ts type-equiv ```ts type-equiv
/**
* Opaque file-version token — the freshness token a write/edit guards against.
* The local backend derives it from high-resolution stat identity and freshness
* fields; a remote backend might use a revision id. The policy layer records it
* for stale checks; consumers may display related metadata but MUST NOT
* interpret this token.
*/
type FsVersion = Branded<'FsVersion'> type FsVersion = Branded<'FsVersion'>
``` ```
`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the tool reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. `stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the tool reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure.
```ts type-equiv ```ts type-equiv
/**
* Metadata about a target — what {@link FileSystem.stat} returns. Lets the
* policy layer reject directories/special files before reading and choose
* `readText` vs `streamText` from `size` without probing by failure. `version`
* is the freshness token. `undefined` from `stat` means the target is absent.
*/
interface FsInfo { interface FsInfo {
/** Opaque freshness token of the target right now. */
version: FsVersion version: FsVersion
/** Whether the target is a regular file, a directory, or something else. */
type: 'file' | 'directory' | 'other' type: 'file' | 'directory' | 'other'
/** Byte size of a regular file, when the backend can report it. */
size?: number size?: number
} }
``` ```
@@ -40,9 +70,18 @@ interface FsInfo {
`lstat` is the path-level no-follow metadata primitive. It takes a path instead of an `FsTarget` because `resolve` intentionally follows symlinks to produce stable identity; consumers that need trust-boundary checks can call `lstat` first and reject `symlink` before resolving. `lstat` is the path-level no-follow metadata primitive. It takes a path instead of an `FsTarget` because `resolve` intentionally follows symlinks to produce stable identity; consumers that need trust-boundary checks can call `lstat` first and reject `symlink` before resolving.
```ts type-equiv ```ts type-equiv
/**
* Metadata about a path without following the final path component when it is a
* symbolic link. Unlike {@link FsInfo}, this path-level probe can report
* `symlink` so consumers with trust-boundary rules can reject repository-owned
* links before resolving a target.
*/
interface FsPathInfo { interface FsPathInfo {
/** Opaque freshness token of the path entry right now. */
version: FsVersion version: FsVersion
/** Whether the path entry is a regular file, directory, symlink, or other. */
type: 'file' | 'directory' | 'symlink' | 'other' type: 'file' | 'directory' | 'symlink' | 'other'
/** Byte size of the path entry, when the backend can report it. */
size?: number size?: number
} }
``` ```
@@ -50,11 +89,20 @@ interface FsPathInfo {
`listDir` returns direct child entries in stable name order. Each entry carries the child basename, type, resolved target, and cheap metadata when the backend can report it. It must not read file contents, so `size` is only for regular files and `version` is metadata-derived. Broken or disappeared children may be returned as `other` without metadata; permission or backend I/O failures while listing or resolving child metadata fail the whole listing with `FS_PERMISSION_DENIED` or `FS_IO_ERROR`. `listDir` returns direct child entries in stable name order. Each entry carries the child basename, type, resolved target, and cheap metadata when the backend can report it. It must not read file contents, so `size` is only for regular files and `version` is metadata-derived. Broken or disappeared children may be returned as `other` without metadata; permission or backend I/O failures while listing or resolving child metadata fail the whole listing with `FS_PERMISSION_DENIED` or `FS_IO_ERROR`.
```ts type-equiv ```ts type-equiv
/**
* One direct child returned by {@link FileSystem.listDir}. Listing returns
* metadata and resolved targets only; it must not read file contents.
*/
interface FsDirEntry { interface FsDirEntry {
/** Basename of the child inside the listed directory. */
name: string name: string
/** Whether the child is a regular file, a directory, or something else. */
type: 'file' | 'directory' | 'other' type: 'file' | 'directory' | 'other'
/** Resolved child target for follow-up operations. */
target: FsTarget target: FsTarget
/** Opaque freshness token when the backend can report metadata cheaply. */
version?: FsVersion version?: FsVersion
/** Byte size of a regular file, when the backend can report it. */
size?: number size?: number
} }
``` ```
@@ -64,16 +112,33 @@ interface FsDirEntry {
Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape.
```ts type-equiv ```ts type-equiv
/**
* Guarded write intent. `createIfAbsent` rejects an existing target with
* `FS_NOT_OBSERVED`; `replaceIfVersion` rejects absence or mismatch with
* `FS_STALE_VERSION`. Omitting the intent from `writeText` means unconditional
* create-or-overwrite, not a third union arm.
*/
type FsWriteIntent = type FsWriteIntent =
| { kind: 'createIfAbsent' } | { kind: 'createIfAbsent' }
| { kind: 'replaceIfVersion'; version: FsVersion } | { kind: 'replaceIfVersion'; version: FsVersion }
``` ```
```ts type-equiv ```ts type-equiv
/** Outcome of a full-file write. */
interface FsWriteOutcome { interface FsWriteOutcome {
/** Whether the write created a new file or replaced an existing one. */
operation: 'create' | 'update' operation: 'create' | 'update'
/** Opaque version of the file after the write. */
version: FsVersion version: FsVersion
/**
* The file's content BEFORE the write, or `null` when the file did not exist
* (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text
* (the diff basis), never a diff — a consumer computes the result-time
* contextual diff from `before`/`after` when `before` is present, else falls
* back to a whole-file diff.
*/
before: string | null before: string | null
/** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */
after: string after: string
} }
``` ```
@@ -81,17 +146,29 @@ interface FsWriteOutcome {
`editText` is a provider-level mutation, not a `read` plus `write` composed elsewhere. When guarded it verifies the expected version BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not a match failure against newer content); unguarded it edits the current content. Either way it applies the replacement and writes atomically — keeping matching, line-ending handling, the stale check, and atomic replacement inside one mutation critical section — and a missing target reports `FS_STALE_VERSION` on both paths. `editText` is a provider-level mutation, not a `read` plus `write` composed elsewhere. When guarded it verifies the expected version BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not a match failure against newer content); unguarded it edits the current content. Either way it applies the replacement and writes atomically — keeping matching, line-ending handling, the stale check, and atomic replacement inside one mutation critical section — and a missing target reports `FS_STALE_VERSION` on both paths.
```ts type-equiv ```ts type-equiv
/** A literal-replacement edit request. */
interface FsEditRequest { interface FsEditRequest {
/** Literal non-empty text to replace. Must match exactly (after line-ending normalization). */
oldString: string oldString: string
/** Literal replacement text. An empty string deletes the matched text. */
newString: string newString: string
/** Replace every match instead of requiring exactly one. */
replaceAll: boolean replaceAll: boolean
} }
``` ```
```ts type-equiv ```ts type-equiv
/** Outcome of a literal edit. */
interface FsEditOutcome { interface FsEditOutcome {
/** Opaque version of the file after the edit. */
version: FsVersion version: FsVersion
/**
* The file's content BEFORE the edit. Raw storage text (LF-normalized by the
* backend), never a diff — a consumer computes the result-time contextual diff
* (the applied hunk with context) from `before`/`after`.
*/
before: string before: string
/** The file's content AFTER the edit. */
after: string after: string
} }
``` ```
@@ -107,8 +184,20 @@ interface FsEditOutcome {
The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages. The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages.
```ts type-equiv ```ts type-equiv
/**
* Minimal structural view of a tool execution the policy plugin needs to derive
* an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies
* this shape, so the tool passes its `exec` straight through as the opaque
* `object` actor on the `fs/*` events; this plugin narrows that actor to this
* shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`.
*
* The owner is `agent.session` when present. It is treated as an opaque object
* identity (a `WeakMap` key); this package never reads any of its fields.
*/
interface FsPolicyExec { interface FsPolicyExec {
/** The agent on whose behalf the call runs, when there is one. */
agent?: { agent?: {
/** The session that owns observed-file state, used as an opaque key. */
session?: object session?: object
} }
} }
@@ -119,10 +208,15 @@ interface FsPolicyExec {
A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits `fs/observed` with the stat's version directly), so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits `fs/observed` with the stat's version directly), so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin.
```ts type-equiv ```ts type-equiv
/** Outcome of a bounded text read — what {@link formatReadOutput} renders. */
interface FileReadOutcome { interface FileReadOutcome {
/** 1-based first line requested. */
offset: number offset: number
/** Returned lines, already numbered. */
lines: FileTextLine[] lines: FileTextLine[]
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
totalLines: number totalLines: number
/** Whether selected output hit the byte cap before EOF or the requested limit. */
truncatedByBytes?: true truncatedByBytes?: true
} }
``` ```
@@ -136,6 +230,11 @@ Observed state is a `WeakMap<owner, Map<targetKey, { version }>>` held inside th
Filesystem failures use stable `FsErrorCode` strings carried by `FsError` (`HarnessError`). The tool registry preserves `{ name, code }` on error results, so retry, permission, and UI layers can branch without parsing text. Filesystem failures use stable `FsErrorCode` strings carried by `FsError` (`HarnessError`). The tool registry preserves `{ name, code }` on error results, so retry, permission, and UI layers can branch without parsing text.
```ts type-equiv ```ts type-equiv
/**
* Stable, machine-routable codes for filesystem failures. Carried on
* {@link FsError}; the tool registry surfaces `{ name, code }` on `isError`
* results so retry/permission/UI layers can branch without parsing messages.
*/
type FsErrorCode = type FsErrorCode =
| 'FS_NOT_FOUND' | 'FS_NOT_FOUND'
| 'FS_NOT_DIRECTORY' | 'FS_NOT_DIRECTORY'

View File

@@ -9,6 +9,13 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts)
A streaming response interleaves several typed blocks (text, reasoning, multiple tool calls). `index` ties each delta to its block; `block-end` carries the fully-assembled `ContentBlock` so consumers don't have to re-assemble deltas themselves. It is a **closed** discriminated union — a `switch` over `type` ends with `assertNever`, so adding a variant breaks compilation at every consumer that must handle it. A streaming response interleaves several typed blocks (text, reasoning, multiple tool calls). `index` ties each delta to its block; `block-end` carries the fully-assembled `ContentBlock` so consumers don't have to re-assemble deltas themselves. It is a **closed** discriminated union — a `switch` over `type` ends with `assertNever`, so adding a variant breaks compilation at every consumer that must handle it.
```ts type-equiv ```ts type-equiv
/**
* Raw streaming protocol emitted by adapters.
* Block indexes correlate interleaved deltas, and `block-end` carries the
* assembled block. Adapters emit usage before the terminal finish and nothing
* afterward; tool arguments remain raw JSON strings. Failures either throw or
* end with `error`/`aborted`, and consumers must handle both paths.
*/
type StreamChunk = type StreamChunk =
| { type: 'block-start'; index: number; blockType: ContentBlockType } | { type: 'block-start'; index: number; blockType: ContentBlockType }
| { type: 'text-delta'; index: number; text: string } | { type: 'text-delta'; index: number; text: string }
@@ -41,9 +48,19 @@ This contract was pinned down by two deliberately independent implementations: `
The static public application identity every adapter sends to providers ([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts)). `attributionHeaders(identity?)` maps it to the standard `User-Agent` header only; OpenRouter-specific app attribution headers are intentionally not supported by this contract. The default `APP_IDENTITY` sources its version from the package manifest; every field is a public product fact - no secrets, paths, session ids, or per-user identifiers, and nothing per-request may influence the values. Rationale: [Mandatory `User-Agent` attribution](../rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). The static public application identity every adapter sends to providers ([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts)). `attributionHeaders(identity?)` maps it to the standard `User-Agent` header only; OpenRouter-specific app attribution headers are intentionally not supported by this contract. The default `APP_IDENTITY` sources its version from the package manifest; every field is a public product fact - no secrets, paths, session ids, or per-user identifiers, and nothing per-request may influence the values. Rationale: [Mandatory `User-Agent` attribution](../rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md).
```ts type-equiv ```ts type-equiv
/**
* Static public application identity sent to LLM providers.
*
* Every field is a public product fact, safe on every request: no secrets,
* local paths, session ids, prompt text, or per-user identifiers belong here,
* and nothing per-request may influence the values.
*/
interface AppIdentity { interface AppIdentity {
/** `User-Agent` product token (lowercase, hyphenated). */
product: string product: string
/** Product version; sourced from package metadata, never hand-copied. */
version: string version: string
/** Public home URL of the app, used as the `User-Agent` comment. */
url: string url: string
} }
``` ```
@@ -53,6 +70,14 @@ interface AppIdentity {
Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's `prompt_tokens`) subtract them back out. Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's `prompt_tokens`) subtract them back out.
```ts type-equiv ```ts type-equiv
/**
* Token accounting for one model call (cache fields are optional).
*
* Counts are DISJOINT: `inputTokens` is uncached input only; cached input is
* reported separately as `cacheReadTokens`/`cacheWriteTokens` (billed input =
* sum of the three). Adapters whose providers fold cache hits into a total
* prompt count (DeepSeek's `prompt_tokens`) subtract them out.
*/
interface TokenUsage { interface TokenUsage {
inputTokens: number inputTokens: number
outputTokens: number outputTokens: number
@@ -73,6 +98,10 @@ interface TokenUsage {
`ContentBlockType` (the key set the `index`-correlated blocks carry) derives from `ContentBlockMap`: `ContentBlockType` (the key set the `index`-correlated blocks carry) derives from `ContentBlockMap`:
```ts type-equiv ```ts type-equiv
/**
* Merge-extensible content blocks keyed by `type`. New core blocks must land
* with adapter, UI, and compaction support.
*/
interface ContentBlockMap { interface ContentBlockMap {
'text': TextBlock 'text': TextBlock
'reasoning': ReasoningBlock 'reasoning': ReasoningBlock

View File

@@ -17,6 +17,11 @@ A backend that reloads a log crashed mid-turn finds an open `turn/start` with no
`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. `SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee.
```ts type-equiv ```ts type-equiv
/**
* A backend-resolved, per-session local artifact location. The path is an
* absolute target path and can name an artifact that has not materialized yet.
* Consumers must treat it as a location hint, never as an authorization token.
*/
interface SessionLocation { interface SessionLocation {
/** Backend-specific artifact kind, for example `jsonl`. */ /** Backend-specific artifact kind, for example `jsonl`. */
readonly kind: string readonly kind: string
@@ -32,6 +37,9 @@ Per-session metadata travels **separately** from the event log: format version,
Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts)
```ts type-equiv ```ts type-equiv
/**
* Immutable validated storage metadata, kept outside the conversation event log.
*/
interface SessionHeader { interface SessionHeader {
/** /**
* On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the * On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the
@@ -48,13 +56,8 @@ interface SessionHeader {
/** The session this one was forked from (seed lineage), if any. */ /** The session this one was forked from (seed lineage), if any. */
readonly parentSession?: SessionId readonly parentSession?: SessionId
/** /**
* How many leading events were INHERITED via a seed rather than produced by * How many leading events were inherited through a seed. Persisting this
* this session — the seed boundary. Set when a fork seeds a child with a * boundary lets resume and replay distinguish parent history from child work.
* prefix of the parent's log (= the seeded prefix length); absent/0 means the
* session produced all its own events. Persisted so a reload reconstructs the
* boundary instead of re-deriving it from the full stored log, and so a replay
* harness can skip the inherited prefix when deriving the child's OWN script
* (the seeded events are the parent's, not this child's model calls).
*/ */
readonly seedLength?: number readonly seedLength?: number
} }
@@ -65,20 +68,17 @@ interface SessionHeader {
Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, and — only when reconstructing a persisted session — the original `createdAt` to preserve it.
```ts type-equiv ```ts type-equiv
/**
* Options for creating a {@link Session} via the store. `seed` replays/forks
* an existing event log; `meta` carries the caller-supplied storage fields the
* store folds into a {@link SessionHeader}.
*/
interface CreateSessionOptions { interface CreateSessionOptions {
/** Events to seed the new session with (replay/fork). */ /** Events to seed the new session with (replay/fork). */
readonly seed?: readonly SessionEvent[] readonly seed?: readonly SessionEvent[]
/** /**
* Creation metadata. The store fills in `version`/`id` and defaults * Storage metadata read once before publication. `seedLength` is explicit
* `createdAt` to now; the caller supplies the storage-level fields (validated * because a resumed seed contains the full stored log, not only its inherited prefix.
* absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and
* — when reconstructing a persisted session — the original `createdAt` to
* preserve it).
*
* `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction
* (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full
* length, not the original boundary — the caller must pass the persisted
* boundary back. A fresh fork passes its actual seeded-prefix length.
*/ */
readonly meta?: { readonly meta?: {
readonly cwd?: string readonly cwd?: string

View File

@@ -9,18 +9,30 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox
`SandboxMode` governs filesystem effects only. `read-only` denies writes except the required `/dev/null` sink; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. `SandboxMode` governs filesystem effects only. `read-only` denies writes except the required `/dev/null` sink; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary.
```ts type-equiv ```ts type-equiv
/**
* File-effect policy for confined processes. `read-only` permits only required
* sinks such as `/dev/null`; `workspace-write` also permits the workspace and a
* backend-defined temp area; `danger-full-access` bypasses confinement. Network
* and process visibility are outside this vocabulary.
*/
type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
``` ```
Only the first two modes can be sent to a provider. A `danger-full-access` consumer spawns its original argv and does not call `ctx.sandbox`. Only the first two modes can be sent to a provider. A `danger-full-access` consumer spawns its original argv and does not call `ctx.sandbox`.
```ts type-equiv ```ts type-equiv
/** A confining (non-`danger-full-access`) mode — the modes a {@link SandboxPolicy} can carry. */
type ConfinedSandboxMode = Exclude<SandboxMode, 'danger-full-access'> type ConfinedSandboxMode = Exclude<SandboxMode, 'danger-full-access'>
``` ```
Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction.
```ts type-equiv ```ts type-equiv
/**
* Enforcement completeness for this host. `partial` means an active backend or
* older kernel ABI cannot govern every promised file effect; callers requiring
* an absolute boundary must not treat it as `full`.
*/
type SandboxEnforcement = 'full' | 'partial' type SandboxEnforcement = 'full' | 'partial'
``` ```
@@ -29,6 +41,15 @@ type SandboxEnforcement = 'full' | 'partial'
The policy is fully resolved and carried per call. This permits concurrent consumers and one-shot escalated retries to ask the same provider for different boundaries without mutating provider state. The policy is fully resolved and carried per call. This permits concurrent consumers and one-shot escalated retries to ask the same provider for different boundaries without mutating provider state.
```ts type-equiv ```ts type-equiv
/**
* What one confined execution is allowed to touch — carried PER CALL, not
* fixed on the provider: two consumers may confine under different policies
* at the same instant (bash under `read-only` while a confined child agent
* needs its state directory writable), and an approved escalated retry is a
* new call with a wider policy. Defaulting/resolution is the consumer's
* explicit step (its config owns the fallback chain); the provider treats
* the policy as fully specified.
*/
interface SandboxPolicy { interface SandboxPolicy {
/** The file-effect mode this execution runs under. */ /** The file-effect mode this execution runs under. */
mode: ConfinedSandboxMode mode: ConfinedSandboxMode
@@ -42,6 +63,11 @@ interface SandboxPolicy {
`ConfinedArgv` is what the consumer spawns. Besides the replacement argv, it carries the backend's enforcement fact and two orthogonal stderr dialects. `denialSignatures` identify the confined command being blocked while the sandbox works correctly. `runnerFailureSignatures` identify the sandbox runner refusing or failing before it executes the command; consumers check these first and surface a sandbox infrastructure failure, never an ordinary task failure. `ConfinedArgv` is what the consumer spawns. Besides the replacement argv, it carries the backend's enforcement fact and two orthogonal stderr dialects. `denialSignatures` identify the confined command being blocked while the sandbox works correctly. `runnerFailureSignatures` identify the sandbox runner refusing or failing before it executes the command; consumers check these first and surface a sandbox infrastructure failure, never an ordinary task failure.
```ts type-equiv ```ts type-equiv
/**
* A {@link SandboxProvider.confine} result: the argv to spawn in place of
* the caller's own, plus the enforcement completeness the selected backend
* achieves for it.
*/
interface ConfinedArgv { interface ConfinedArgv {
/** The wrapped argv (runner, profile, separator, then the caller's argv). */ /** The wrapped argv (runner, profile, separator, then the caller's argv). */
argv: string[] argv: string[]
@@ -57,17 +83,9 @@ interface ConfinedArgv {
*/ */
denialSignatures: readonly string[] denialSignatures: readonly string[]
/** /**
* How the RUNNER ITSELF failing identifies itself: case-insensitive stderr * Case-insensitive signatures for runner failure before command execution.
* substrings produced when the sandbox binary is missing, refuses its * Consumers check these before denial signatures: runner failure means the
* profile, or fails closed before exec'ing the command (`bwrap: `, * command never ran, while denial means confinement worked and blocked it.
* `landlock-run: `, `sandbox-exec: ` — each covers both the runner's own
* error prefix and the shell's runner-not-found message). ORTHOGONAL to
* {@link denialSignatures}: a denial is the confined COMMAND being blocked
* (the sandbox working as designed); a runner failure means the command
* NEVER RAN and must surface as a sandbox failure, not a task failure —
* consumers check these signatures FIRST (a runner's own error text may
* contain denial words, e.g. an unopenable grant root reporting
* `Permission denied`).
*/ */
runnerFailureSignatures: readonly string[] runnerFailureSignatures: readonly string[]
} }

View File

@@ -9,12 +9,18 @@ Source: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index
`ScopeKey` is an opaque object identity. The shipped loop uses the live `Agent` object as its own key, but the primitive never inspects the object. `ScopeKey` is an opaque object identity. The shipped loop uses the live `Agent` object as its own key, but the primitive never inspects the object.
```ts type-equiv ```ts type-equiv
/** An opaque, identity-compared scope key. */
type ScopeKey = object type ScopeKey = object
``` ```
`Scoped<T>` is the compile-time brand on the opaque routing receiver returned by `scopeTarget(base, key)`. Scope-filtered event declarations require this carrier as their `this` type, while the real event subject remains an explicit argument. `Scoped<T>` is the compile-time brand on the opaque routing receiver returned by `scopeTarget(base, key)`. Scope-filtered event declarations require this carrier as their `this` type, while the real event subject remains an explicit argument.
```ts type-equiv ```ts type-equiv
/**
* A routing-only event receiver built by {@link scopeTarget}. The type
* parameter records the subject type for dispatch checking; the carrier does
* not expose the subject's properties. Event payloads carry the real subject.
*/
type Scoped<T extends object> = object & { readonly [ScopedBrand]: T } type Scoped<T extends object> = object & { readonly [ScopedBrand]: T }
``` ```
@@ -23,9 +29,13 @@ type Scoped<T extends object> = object & { readonly [ScopedBrand]: T }
`Scope` pairs the tagged registration context with two teardown surfaces. `rawDispose` preserves the exact Cordis disposer identity needed by an ordered composite effect; `dispose()` is the public shared quiescence boundary for direct and racing callers. `Scope` pairs the tagged registration context with two teardown surfaces. `rawDispose` preserves the exact Cordis disposer identity needed by an ordered composite effect; `dispose()` is the public shared quiescence boundary for direct and racing callers.
```ts type-equiv ```ts type-equiv
/** A minted registration scope and its quiescent disposal boundaries. */
interface Scope { interface Scope {
/** Context through which scope-owned registrations are made. */
ctx: Context ctx: Context
/** Exact Cordis disposer, used when nesting this scope in an ordered composite effect. */
rawDispose: () => Promise<void> | void rawDispose: () => Promise<void> | void
/** Dispose every scope-owned registration; racing calls await the same completion. */
dispose(): Promise<void> dispose(): Promise<void>
} }
``` ```

View File

@@ -9,23 +9,34 @@ Source: [`packages/session-query/session-query/src/types.ts`](../../packages/ses
`SessionRecord` is returned by the cross-corpus list. It exposes source availability independently from the cloned live-preferred header. `SessionEventRecord` is a lightweight raw-log projection; classification uses the same `foldSurface()` transitions as model-history derivation. `SessionRecord` is returned by the cross-corpus list. It exposes source availability independently from the cloned live-preferred header. `SessionEventRecord` is a lightweight raw-log projection; classification uses the same `foldSurface()` transitions as model-history derivation.
```ts type-equiv ```ts type-equiv
export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' /** Whether an event is current model context, replaced context, or raw-log-only. */
type SessionEventSurface = 'current' | 'shadowed' | 'log-only'
``` ```
```ts type-equiv ```ts type-equiv
export interface SessionRecord { /** Lightweight identity and source availability for one logical session. */
interface SessionRecord {
/** Cloned session header selected from the live-preferred corpus. */
header: SessionHeader header: SessionHeader
/** Whether the id currently exists in `ctx.sessions`. */
live: boolean live: boolean
/** Whether the active persistence backend currently materializes the id. */
persisted: boolean persisted: boolean
} }
``` ```
```ts type-equiv ```ts type-equiv
export interface SessionEventRecord { /** Lightweight metadata for one event within a logical session. */
interface SessionEventRecord {
/** Session that owns the event. */
sessionId: SessionId sessionId: SessionId
/** Monotonic event seq within the session. */
seq: number seq: number
/** Discriminant of the session event. */
type: SessionEventType type: SessionEventType
/** Event timestamp in Unix epoch milliseconds. */
time: number time: number
/** Event placement in the folded session surface. */
surface: SessionEventSurface surface: SessionEventSurface
} }
``` ```
@@ -35,24 +46,35 @@ export interface SessionEventRecord {
`SessionLineageTrace` carries known parents in immediate-to-outward order and a forest of recursively nested direct descendants. The completeness discriminant makes a known root and a missing parent mutually exclusive. `SessionLineageTrace` carries known parents in immediate-to-outward order and a forest of recursively nested direct descendants. The completeness discriminant makes a known root and a missing parent mutually exclusive.
```ts type-equiv ```ts type-equiv
export interface SessionLineageNode { /** Recursive descendant node in a session-lineage trace. */
interface SessionLineageNode {
/** Detached logical-corpus record for this descendant. */
session: SessionRecord session: SessionRecord
/** Direct children, each carrying its own recursive descendants. */
descendants: SessionLineageNode[] descendants: SessionLineageNode[]
} }
``` ```
```ts type-equiv ```ts type-equiv
export type SessionLineageTrace = { /** Known ancestry and descendants for one logical session. */
type SessionLineageTrace = {
/** Detached record for the session that was traced. */
target: SessionRecord target: SessionRecord
/** Known parents from the immediate parent outward. */
ancestors: SessionRecord[] ancestors: SessionRecord[]
/** Complete known descendant trees rooted at the target's direct children. */
descendants: SessionLineageNode[] descendants: SessionLineageNode[]
} & ( } & (
| { | {
/** The complete parent chain is present in the logical corpus. */
complete: true complete: true
/** Detached record at the top of the complete lineage. */
root: SessionRecord root: SessionRecord
} }
| { | {
/** The parent chain leaves the visible logical corpus. */
complete: false complete: false
/** First parent id that is not present in the logical corpus. */
unresolvedParentId: SessionId unresolvedParentId: SessionId
} }
) )
@@ -63,20 +85,31 @@ export type SessionLineageTrace = {
The request addresses one raw seq and optional neighboring counts. The result carries a `SessionHeader` rather than availability flags so a known live target can remain independent of persistence health. The request addresses one raw seq and optional neighboring counts. The result carries a `SessionHeader` rather than availability flags so a known live target can remain independent of persistence health.
```ts type-equiv ```ts type-equiv
export interface SessionEventReadRequest { /** Request for one event plus raw neighboring log context. */
interface SessionEventReadRequest {
/** Session that owns the target event. */
sessionId: SessionId sessionId: SessionId
/** Target event seq. */
seq: number seq: number
/** Number of preceding raw events to include. */
before?: number before?: number
/** Number of following raw events to include. */
after?: number after?: number
} }
``` ```
```ts type-equiv ```ts type-equiv
export interface SessionEventWindow { /** Full target event and a bounded raw-log window. */
interface SessionEventWindow {
/** Cloned header for the live-preferred source read. */
session: SessionHeader session: SessionHeader
/** Full cloned target event. */
target: SessionEvent target: SessionEvent
/** Full cloned events from `startSeq` through `endSeq`. */
events: SessionEvent[] events: SessionEvent[]
/** First seq included in `events`. */
startSeq: number startSeq: number
/** Last seq included in `events`. */
endSeq: number endSeq: number
} }
``` ```
@@ -86,19 +119,29 @@ export interface SessionEventWindow {
Event traces distinguish positional surface replacement from logged provenance. Every seq list contains direct links except `replacementChain`, which follows immediate replacers from the target to the final positional replacement. Event traces distinguish positional surface replacement from logged provenance. Every seq list contains direct links except `replacementChain`, which follows immediate replacers from the target to the final positional replacement.
```ts type-equiv ```ts type-equiv
export interface SessionEventTraceRequest { /** Request for direct surface and provenance relationships around one event. */
interface SessionEventTraceRequest {
/** Session that owns the target event. */
sessionId: SessionId sessionId: SessionId
/** Target event seq. */
seq: number seq: number
} }
``` ```
```ts type-equiv ```ts type-equiv
export interface SessionEventTrace { /** Direct surface and provenance relationships for one event. */
interface SessionEventTrace {
/** Lightweight target record. */
target: SessionEventRecord target: SessionEventRecord
/** Immediate positional replacement event, when the target was shadowed. */
replacedBy?: number replacedBy?: number
/** Positional replacers from the immediate replacement to the final replacement. */
replacementChain: number[] replacementChain: number[]
/** Surface nodes directly removed when the target itself performed a replacement. */
replacedEventSeqs: number[] replacedEventSeqs: number[]
/** Direct logged provenance sources in their recorded order. */
sourceEventSeqs: number[] sourceEventSeqs: number[]
/** Later events that directly name the target as a provenance source, in log order. */
derivedEventSeqs: number[] derivedEventSeqs: number[]
} }
``` ```
@@ -108,7 +151,8 @@ export interface SessionEventTrace {
The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata. The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata.
```ts type-equiv ```ts type-equiv
export type SessionQueryErrorCode = /** Stable machine-routable failure taxonomy for exact session reads and traces. */
type SessionQueryErrorCode =
| 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_EVENT_NOT_FOUND'
| 'SESSION_QUERY_INVALID_CONFIG' | 'SESSION_QUERY_INVALID_CONFIG'
| 'SESSION_QUERY_INVALID_LINEAGE' | 'SESSION_QUERY_INVALID_LINEAGE'

View File

@@ -9,6 +9,7 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t
`ContextEnvelope` selects the standard tagged projection or preserves a producer-owned complete frame. The latter changes framing only; the event remains a user-role `context/message` in chronological history. `ContextEnvelope` selects the standard tagged projection or preserves a producer-owned complete frame. The latter changes framing only; the event remains a user-role `context/message` in chronological history.
```ts type-equiv ```ts type-equiv
/** Canonical context-tag framing, or caller-owned framing rendered verbatim. */
type ContextEnvelope = 'context' | 'raw' type ContextEnvelope = 'context' | 'raw'
``` ```
@@ -17,30 +18,43 @@ type ContextEnvelope = 'context' | 'raw'
The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site. The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site.
```ts type-equiv ```ts type-equiv
/**
* The merge-extensible, append-only source of truth for an agent interaction.
* Message history is derived from this log. Every event is lossless JSON and
* sequence numbers stay contiguous, including raw chunks, so persistence can
* store the canonical log verbatim.
*/
interface SessionEventMap { interface SessionEventMap {
/**
* 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 } 'turn/start': { turn: number; trigger: TurnTrigger }
/**
* 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 } 'turn/end': { turn: number; reason: TurnEndReason }
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
'step/start': { turn: number; step: number } 'step/start': { turn: number; step: number }
/** Closes step `step` of turn `turn`. */
'step/end': { turn: number; step: number } 'step/end': { turn: number; step: number }
/** A user-visible prompt (queued message drained at turn start). */ /** A user-visible prompt (queued message drained at turn start). */
'user/message': { content: ContentBlock[]; source: MessageSource } 'user/message': { content: ContentBlock[]; source: MessageSource }
/** /**
* A queued prompt an `agent/prompt-submit` listener VETOED — the durable * Durable record of a prompt veto and its reason. It is log-only: the blocked
* record of a blocked prompt and why. Appended in place of the `user/message` * prompt never enters the model-visible surface, including in a mixed batch.
* the prompt would have become, so the block survives replay even in a MIXED
* batch where another queued prompt is allowed (there the turn does not end
* `rejected`, so the boundary reason alone would not preserve it). `content`
* is the original prompt the listener rejected; `reason` is the veto text
* ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a
* blocked prompt produces no LLM message and never reaches `deriveMessages()`.
*/ */
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
/** /**
* In-session context injection (file-change notices, subdir AGENTS.md, * In-session context injection (file-change notices, subdir AGENTS.md,
* skill content, cron notifications, …). Rendered into the derived history * skill content, cron notifications, …). Rendered into the derived history
* as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller * as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller
* supply its own complete framing; `meta` is persisted JSON hidden from the * own the complete model-facing frame; `meta` is durable JSON state omitted
* model. * from the model projection.
*/ */
'context/message': { 'context/message': {
content: ContentBlock[] content: ContentBlock[]
@@ -57,34 +71,29 @@ interface SessionEventMap {
* usage record). `usage` is absent when the adapter reported none. * usage record). `usage` is absent when the adapter reported none.
*/ */
'assistant/message': { turn: number; step: number; content: ContentBlock[]; provenance: AssistantProvenance; usage?: TokenUsage } 'assistant/message': { turn: number; step: number; content: ContentBlock[]; provenance: AssistantProvenance; usage?: TokenUsage }
/**
* 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 } 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
/**
* 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 } 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown }
/** Steering content injected between steps of a running turn. */ /** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
/** /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
* The agent's whole todo list, carried as a full snapshot and replaced
* wholesale on each write — the current list is the most recent `todo/write`
* (last-write-wins on replay, no fold). Appended by an owning agent via
* `session.append('todo/write', { todos })`.
*
* NOT a {@link SurfaceEventType}: it produces no LLM message and never reaches
* `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface —
* it is durable, replayable UI state, distinct from the conversation history.
* It is a `SessionEventMap` member riding the existing `session/event` emit,
* not a first-class Cordis `interface Events` notification, so it has no
* cordis-catalog row.
*/
'todo/write': { todos: TodoItem[] } 'todo/write': { todos: TodoItem[] }
/** /**
* Full snapshot of the {@link EpochHeader} the NEXT request is built under, * Full header for the next request, appended inside its step before dispatch.
* with the {@link RequestHeaderReason} it was recorded whole. Appended by * It is log-only; the latest snapshot reconstructs the request header.
* the loop inside the step, before dispatch, on a loop instance's first
* request-building step (`'initial'`/`'resume'`) or when a later request's
* header changes (`'change'`); always records what the request actually
* used, post-`agent/request`. Reconstruction reads the latest snapshot. NOT a
* {@link SurfaceEventType}: it produces no LLM message — it is the request
* envelope, logged so every request is a pure function of the session log
* (the reconstructability RFC).
*/ */
'request/header': { header: EpochHeader; reason: RequestHeaderReason } 'request/header': { header: EpochHeader; reason: RequestHeaderReason }
} }
@@ -95,8 +104,21 @@ interface SessionEventMap {
The unit of the `todo/write` event's whole-list snapshot. Deliberately minimal — a `content` line and a three-state `status` (no id, priority, or `activeForm`): the list is replaced wholesale on every write, so entries need no stable identity, and the status triple is exactly the ACP `PlanEntryStatus`, so a UI bridge can map a todo list onto an ACP `plan` 1:1 (synthesizing the priority ACP additionally requires). See the [todo_write RFC](../rfc/implemented/feature/2026-06-29-todo-write-tool.md). The unit of the `todo/write` event's whole-list snapshot. Deliberately minimal — a `content` line and a three-state `status` (no id, priority, or `activeForm`): the list is replaced wholesale on every write, so entries need no stable identity, and the status triple is exactly the ACP `PlanEntryStatus`, so a UI bridge can map a todo list onto an ACP `plan` 1:1 (synthesizing the priority ACP additionally requires). See the [todo_write RFC](../rfc/implemented/feature/2026-06-29-todo-write-tool.md).
```ts type-equiv ```ts type-equiv
export interface TodoItem { /**
* One entry in an agent's todo list — the unit of the `todo/write`
* {@link SessionEventMap} event's whole-list snapshot.
*
* Deliberately minimal: a human-readable `content` line and a three-state
* `status`. No id, priority, or `activeForm` — the list is replaced wholesale
* on every write (last-write-wins), so entries need no stable identity, and the
* status triple is exactly the ACP `PlanEntryStatus`, so a UI bridge can map a
* todo list onto an ACP `plan` 1:1 (synthesizing the priority ACP additionally
* requires).
*/
interface TodoItem {
/** What this task is — a short imperative line shown in the UI. */
content: string content: string
/** Lifecycle state. `in_progress` marks the single task being worked now. */
status: 'pending' | 'in_progress' | 'completed' status: 'pending' | 'in_progress' | 'completed'
} }
``` ```
@@ -106,8 +128,13 @@ export interface TodoItem {
The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas + the session prefix) — is logged session state, so every conversation request is a pure function of the log (the reconstructability RFC). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas + the session prefix) — is logged session state, so every conversation request is a pure function of the log (the reconstructability RFC). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message.
```ts type-equiv ```ts type-equiv
export interface EpochHeader { /**
/** The conversation's call configuration (provider + model + sampling scalars). */ * Logged request state outside derived history: call config, system prompt,
* tools, and prefix. The latest full `request/header` snapshot reconstructs it;
* canonical empty optional fields are absent.
*/
interface EpochHeader {
/** The conversation's call configuration (provider, model, and sampling scalars). */
config: LlmCallConfig config: LlmCallConfig
/** Rendered system prompt text; absent for a system-less request. */ /** Rendered system prompt text; absent for a system-less request. */
system?: string system?: string
@@ -131,6 +158,19 @@ Canonical form: an empty system prompt, an empty tool list, and an empty session
A proper discriminated union over `type` (not independent `type`/`data` unions), so `switch (event.type)` narrows `event.data` without casts. `seq` is the monotonic position in the log (`seq = log.length`); `time` is epoch ms. A proper discriminated union over `type` (not independent `type`/`data` unions), so `switch (event.type)` narrows `event.data` without casts. `seq` is the monotonic position in the log (`seq = log.length`); `time` is epoch ms.
```ts type-equiv ```ts type-equiv
/**
* 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.
*/
type SessionEvent<T extends SessionEventType = SessionEventType> = { type SessionEvent<T extends SessionEventType = SessionEventType> = {
[K in SessionEventType]: { [K in SessionEventType]: {
type: K type: K
@@ -143,7 +183,9 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
/** /**
* Seq numbers of events that are provenance sources of this event * Seq numbers of events that are provenance sources of this event
* (e.g. the `assistant/chunk` seqs that built an `assistant/message`, * (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
* or the surface nodes shadowed by a compaction replace node). * 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[] sourceEventSeqs?: number[]
/** How this event entered the surface; absent for non-surface events. */ /** How this event entered the surface; absent for non-surface events. */
@@ -163,7 +205,12 @@ The five message-producing types (`SurfaceEventType` — `user/message`, `assist
### `SurfaceEventType` — the message-producing subset of event types ### `SurfaceEventType` — the message-producing subset of event types
```ts type-equiv ```ts type-equiv
export type SurfaceEventType = /**
* 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}.
*/
type SurfaceEventType =
| 'user/message' | 'user/message'
| 'assistant/message' | 'assistant/message'
| 'tool/result' | 'tool/result'
@@ -174,7 +221,19 @@ export type SurfaceEventType =
### `SurfaceOp` — how an event entered the surface ### `SurfaceOp` — how an event entered the surface
```ts type-equiv ```ts type-equiv
export type SurfaceOp = /**
* 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.
*/
type SurfaceOp =
| 'append' | 'append'
| { op: 'replace'; start: number; end: number } | { op: 'replace'; start: number; end: number }
``` ```
@@ -184,8 +243,18 @@ export type SurfaceOp =
### `SurfaceIntent` — the parameter to `session.append()` ### `SurfaceIntent` — the parameter to `session.append()`
```ts type-equiv ```ts type-equiv
export interface SurfaceIntent { /**
* Surface placement and provenance for {@link Session.append}. Required on
* message-producing events and forbidden on log-only events.
*/
interface SurfaceIntent {
surfaceOp: SurfaceOp surfaceOp: SurfaceOp
/**
* Complete known provenance source set. `assistant/message` may use a
* present empty array for a known empty provider stream; omission means its
* provenance was not recorded. Other surface events require a non-empty set
* when this field is present.
*/
sourceEventSeqs?: number[] sourceEventSeqs?: number[]
} }
``` ```
@@ -199,8 +268,11 @@ The same provenance distinction applies here: only `assistant/message` may carry
`Session.surface` returns the session's stable `SessionSurface` view. The same incremental manager validates append candidates before commit and advances this projection from committed events; callers can observe membership and replacement generation but cannot invoke validation. `Session.surface` returns the session's stable `SessionSurface` view. The same incremental manager validates append candidates before commit and advances this projection from committed events; callers can observe membership and replacement generation but cannot invoke validation.
```ts type-equiv ```ts type-equiv
export interface SessionSurface { /** Readonly live projection of the message-producing session events. */
interface SessionSurface {
/** Current surface event sequences in model-visible order. */
readonly nodes: readonly number[] readonly nodes: readonly number[]
/** Monotonic count of committed positional replacements. */
readonly replaceGeneration: number readonly replaceGeneration: number
} }
``` ```
@@ -210,17 +282,25 @@ export interface SessionSurface {
`foldSurface(events)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. The live manager uses the same transitions without retaining replacement history. Its `replaceGeneration` increments for each committed replacement so incremental consumers can distinguish pure tail growth from a rewrite. `foldSurface(events)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. The live manager uses the same transitions without retaining replacement history. Its `replaceGeneration` increments for each committed replacement so incremental consumers can distinguish pure tail growth from a rewrite.
```ts type-equiv ```ts type-equiv
export interface SurfaceFoldReplacement { /** One replacement operation observed while folding a session surface. */
interface SurfaceFoldReplacement {
/** Seq of the event that replaced the prior surface range. */
seq: number seq: number
/** Declared inclusive start seq of the replaced surface range. */
start: number start: number
/** Declared inclusive end seq of the replaced surface range. */
end: number end: number
/** Actual surface entries removed by the operation, in surface order. */
shadowedSeqs: number[] shadowedSeqs: number[]
} }
``` ```
```ts type-equiv ```ts type-equiv
export interface SurfaceFoldResult { /** Complete result of replaying the surface operations in a session log. */
interface SurfaceFoldResult {
/** Current surface event sequences in model-visible order. */
nodes: number[] nodes: number[]
/** Replacement operations in event order. */
replacements: SurfaceFoldReplacement[] replacements: SurfaceFoldReplacement[]
} }
``` ```
@@ -248,6 +328,10 @@ An explicit `boundary` lets callers fork from a previous completed turn even if
## What started a turn: `TurnTriggerMap` ## What started a turn: `TurnTriggerMap`
```ts type-equiv ```ts type-equiv
/**
* What started a turn.
* Merge-extensible sum type (same pattern as MessageSourceMap).
*/
interface TurnTriggerMap { interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource } message: { kind: 'message'; source: MessageSource }
/** /**
@@ -265,6 +349,9 @@ interface TurnTriggerMap {
## Why a turn ended: `TurnEndReasonMap` ## Why a turn ended: `TurnEndReasonMap`
```ts type-equiv ```ts type-equiv
/**
* Why a turn ended. Merge-extensible sum type.
*/
interface TurnEndReasonMap { interface TurnEndReasonMap {
completed: { kind: 'completed' } completed: { kind: 'completed' }
aborted: { kind: 'aborted'; reason?: string } aborted: { kind: 'aborted'; reason?: string }
@@ -276,26 +363,16 @@ interface TurnEndReasonMap {
*/ */
error: { kind: 'error'; step: number; message: string; code?: string } error: { kind: 'error'; step: number; message: string; code?: string }
disposed: { kind: 'disposed' } disposed: { kind: 'disposed' }
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
'max-tokens': { kind: 'max-tokens' } 'max-tokens': { kind: 'max-tokens' }
/** /**
* The turn's entire prompt batch was BLOCKED before any step ran — every * Policy blocked every prompt before the first step. The zero-step turn still
* drained queued message was vetoed by an `agent/prompt-submit` listener (a * records a balanced durable boundary and the veto reason.
* hook). The turn still opened (so the boundary stays balanced and the block
* is a durable in-turn fact), but ran zero steps. `reason` carries the block
* message from the vetoing decision. Distinct from `aborted` (a user-driven
* cancel) and `error` (a failure): the prompt was rejected by policy, not
* interrupted or broken. A UI renders it as "prompt blocked by hook".
*/ */
rejected: { kind: 'rejected'; reason: string } rejected: { kind: 'rejected'; reason: string }
/** /**
* The turn never ended on its own: the process crashed mid-turn and a * A persistence backend closed a crash-orphaned turn on reload. The loop never
* persistence backend later closed the orphaned (open) turn on reload so the * emits this marker, and the events recorded before the crash remain intact.
* log stays balanced. SYNTHESIZED by the backend's crash-recovery repair — no
* loop ever emits this. Its events are real (they were durably appended before
* the crash) and are PRESERVED, not discarded: a single turn can be huge in a
* long-horizon task (many steps, large tool output), so truncating it would
* lose real work. The marker records that the turn was cut short, not that the
* model completed it. See the session-persistence RFC.
*/ */
interrupted: { kind: 'interrupted' } interrupted: { kind: 'interrupted' }
} }

View File

@@ -11,9 +11,25 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind
Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and skipped without caching the degraded catalog, while malformed candidates fail fast. Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and skipped without caching the degraded catalog, while malformed candidates fail fast.
```ts type-equiv ```ts type-equiv
/** Provider interface for one source of skills, such as local directories or a remote registry. */
interface SkillProvider { interface SkillProvider {
/** Unique provider name in the `ctx.skills` registry. */
readonly name: string readonly name: string
/**
* List available skill candidates for the current lookup context. Provider
* plugins register synchronously during `apply()`; remote initialization,
* authentication, and discovery are awaited inside this method. Implementations
* should settle promptly when `options.signal` aborts.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns provider candidates with precedence ranks and opaque locators.
*/
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]> readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]>
/**
* Load a complete skill body for a previously listed candidate.
* @param candidate - the winning candidate originally returned by this provider.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns the full skill body, or `undefined` if it is no longer loadable.
*/
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined> readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>
} }
``` ```
@@ -37,6 +53,7 @@ The project root is the nearest ancestor containing `.git`; without one, the cur
Skill names are kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). The local provider accepts directory bundles (`<name>/SKILL.md`) and flat Markdown files (`<name>.md`). Nested recursive `**/SKILL.md` discovery is intentionally outside v1. Skill names are kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). The local provider accepts directory bundles (`<name>/SKILL.md`) and flat Markdown files (`<name>.md`). Nested recursive `**/SKILL.md` discovery is intentionally outside v1.
```ts type-equiv ```ts type-equiv
/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */
type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {})
``` ```
@@ -45,13 +62,21 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | '
`SkillSummary` is the registry's model-invocable summary shape. Consumers choose which fields to render; the session catalog uses only `name` and `description`, never the body or absolute file path. `disableModelInvocation` hides a skill from model listings while allowing trusted code to load it by name. `SkillSummary` is the registry's model-invocable summary shape. Consumers choose which fields to render; the session catalog uses only `name` and `description`, never the body or absolute file path. `disableModelInvocation` hides a skill from model listings while allowing trusted code to load it by name.
```ts type-equiv ```ts type-equiv
/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */
interface SkillSummary { interface SkillSummary {
/** Kebab-case identifier used with the `skill` tool. */
readonly name: string readonly name: string
/** Short routing description shown to the model. */
readonly description: string readonly description: string
/** Optional extra routing guidance shown to the model. */
readonly whenToUse?: string readonly whenToUse?: string
/** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
readonly disableModelInvocation?: boolean readonly disableModelInvocation?: boolean
/** Discovery source that produced this winning skill. */
readonly source: SkillSource readonly source: SkillSource
/** Provider that owns this skill body. */
readonly provider: string readonly provider: string
/** Provider-specific base for relative resources. */
readonly resourceBase?: SkillResourceBase readonly resourceBase?: SkillResourceBase
} }
``` ```
@@ -59,10 +84,15 @@ interface SkillSummary {
`SkillCandidate` is the provider-to-registry shape. `locator` is opaque provider state; the registry only stores it and gives it back to the winning provider's `get()`. `SkillCandidate` is the provider-to-registry shape. `locator` is opaque provider state; the registry only stores it and gives it back to the winning provider's `get()`.
```ts type-equiv ```ts type-equiv
/** Provider catalog entry used by the registry to merge and later load skills. */
interface SkillCandidate extends SkillSummary { interface SkillCandidate extends SkillSummary {
/** Lower ranks win duplicate skill names before provider registration order is considered. */
readonly rank: number readonly rank: number
/** Opaque provider-owned handle passed back to `provider.get()`. */
readonly locator: unknown readonly locator: unknown
/** Absolute file path when the provider has one. */
readonly path?: string readonly path?: string
/** Parsed optional metadata object from provider-specific skill frontmatter. */
readonly metadata?: Readonly<Record<string, unknown>> readonly metadata?: Readonly<Record<string, unknown>>
} }
``` ```
@@ -70,6 +100,7 @@ interface SkillCandidate extends SkillSummary {
`SkillDefinition` is the complete parsed result returned by `ctx.skills.get()` and used by the `skill` tool. `resourceBase` tells the tool how to render relative-resource guidance for local, URL, or provider-managed skills. `SkillDefinition` is the complete parsed result returned by `ctx.skills.get()` and used by the `skill` tool. `resourceBase` tells the tool how to render relative-resource guidance for local, URL, or provider-managed skills.
```ts type-equiv ```ts type-equiv
/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */
type SkillResourceBase = type SkillResourceBase =
| { readonly kind: 'directory'; readonly path: string } | { readonly kind: 'directory'; readonly path: string }
| { readonly kind: 'url'; readonly url: string } | { readonly kind: 'url'; readonly url: string }
@@ -77,9 +108,13 @@ type SkillResourceBase =
``` ```
```ts type-equiv ```ts type-equiv
/** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */
interface SkillDefinition extends SkillSummary { interface SkillDefinition extends SkillSummary {
/** Markdown instruction body after any provider-specific metadata removal. */
readonly content: string readonly content: string
/** Absolute file path when the skill came from disk. */
readonly path?: string readonly path?: string
/** Parsed optional metadata object from frontmatter. */
readonly metadata?: Readonly<Record<string, unknown>> readonly metadata?: Readonly<Record<string, unknown>>
} }
``` ```
@@ -87,9 +122,8 @@ interface SkillDefinition extends SkillSummary {
Runtime skills use the same complete shape and participate in the same first-wins collection order. The returned disposer removes the contribution and invalidates discovery caches. Runtime skills use the same complete shape and participate in the same first-wins collection order. The returned disposer removes the contribution and invalidates discovery caches.
```ts type-equiv ```ts type-equiv
type SkillRegistration = Omit<SkillDefinition, 'provider'> & { /** Runtime skill contribution accepted by `ctx.skills.register()`. */
readonly provider?: string type SkillRegistration = Omit<SkillDefinition, 'provider'> & { readonly provider?: string }
}
``` ```
## Lookup and configuration ## Lookup and configuration
@@ -97,8 +131,11 @@ type SkillRegistration = Omit<SkillDefinition, 'provider'> & {
Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Providers receive the same readonly options object used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root. Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Providers receive the same readonly options object used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root.
```ts type-equiv ```ts type-equiv
/** Caller context used for cwd-sensitive and abortable provider work. */
interface SkillLookupOptions { interface SkillLookupOptions {
/** Workspace selector for the current lookup. */
readonly cwd?: string | undefined readonly cwd?: string | undefined
/** Abort discovery or loading work for the current caller. */
readonly signal?: AbortSignal | undefined readonly signal?: AbortSignal | undefined
} }
``` ```
@@ -106,7 +143,9 @@ interface SkillLookupOptions {
The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, and `customSkillDirs`). The consumer owns its catalog description bound. The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, and `customSkillDirs`). The consumer owns its catalog description bound.
```ts type-equiv ```ts type-equiv
/** Skill registry configuration. */
interface Config { interface Config {
/** Maximum number of completed cwd/provider catalogs kept in memory. */
readonly collectCacheMaxEntries?: number readonly collectCacheMaxEntries?: number
} }
``` ```

View File

@@ -9,15 +9,28 @@ Source: [`packages/spill/spill/src/types.ts`](../../packages/spill/spill/src/typ
`saveText` is the whole seam: persist `content` verbatim, return an opaque locator, a backend-supplied retrieval hint, and the exact byte count. The request carries the save-time storage namespace (`owner`), WHERE it came from (`source`, descriptive provenance for naming and inspection — not access control), and a `suggestedName` the backend may use as a naming hint (it is not a path). `saveText` is the whole seam: persist `content` verbatim, return an opaque locator, a backend-supplied retrieval hint, and the exact byte count. The request carries the save-time storage namespace (`owner`), WHERE it came from (`source`, descriptive provenance for naming and inspection — not access control), and a `suggestedName` the backend may use as a naming hint (it is not a path).
```ts type-equiv ```ts type-equiv
/** One request to persist text to a spill artifact. */
interface SaveTextSpill { interface SaveTextSpill {
owner: SpillOwner owner: SpillOwner
source: SpillSource source: SpillSource
/**
* A caller-suggested base name (e.g. `web_fetch.txt`). The backend sanitizes
* it to a single safe path segment before use — it is a hint, never a path.
*/
suggestedName: string suggestedName: string
/** The full text to persist (UTF-8). */
content: string content: string
} }
``` ```
```ts type-equiv ```ts type-equiv
/**
* Save-time storage namespace for a spilled artifact. The session id lets a
* backend group storage under the producing session, but the returned
* {@link SpillLocator} is the model-facing handle. Forked sessions inherit
* locators already present in the seeded log; those artifacts are not copied or
* re-owned, and spills produced after the fork use the child session id.
*/
interface SpillOwner { interface SpillOwner {
sessionId: SessionId sessionId: SessionId
} }
@@ -26,9 +39,17 @@ interface SpillOwner {
`SpillOwner.sessionId` is the save-time storage namespace. Forked sessions inherit existing spill locators from the seeded log; those artifacts are not copied or re-owned, and spills produced after the fork use the child session id. A retention-period cleanup may expire old locators with other old session artifacts; the spill seam does not define a per-session cleanup policy. `SpillOwner.sessionId` is the save-time storage namespace. Forked sessions inherit existing spill locators from the seeded log; those artifacts are not copied or re-owned, and spills produced after the fork use the child session id. A retention-period cleanup may expire old locators with other old session artifacts; the spill seam does not define a per-session cleanup policy.
```ts type-equiv ```ts type-equiv
/**
* Provenance of one spilled artifact — recorded by the backend for a readable
* filename and inspection. Not interpreted for access control; purely
* descriptive.
*/
interface SpillSource { interface SpillSource {
/** The tool whose result was spilled (e.g. `web_fetch`). */
toolName: string toolName: string
/** The model-issued call id the result belongs to. */
callId: CallId callId: CallId
/** A short human label for the artifact (e.g. `result`). */
label: string label: string
} }
``` ```
@@ -36,6 +57,7 @@ interface SpillSource {
## The result ## The result
```ts type-equiv ```ts type-equiv
/** A saved spill artifact: its locator, byte length, and backend-specific retrieval guidance. */
interface SpillRef { interface SpillRef {
locator: SpillLocator locator: SpillLocator
bytes: number bytes: number
@@ -46,6 +68,11 @@ interface SpillRef {
`SpillLocator` is a [branded](core.md#branded-ids) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism. `SpillLocator` is a [branded](core.md#branded-ids) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism.
```ts type-equiv ```ts type-equiv
/**
* Opaque model-facing handle for one spilled artifact. A local backend may use a
* filesystem path; a remote or database backend may use a URI or key. Consumers
* render it with {@link SpillRef.retrievalHint}, but do not parse it.
*/
type SpillLocator = Branded<'SpillLocator'> type SpillLocator = Branded<'SpillLocator'>
``` ```

View File

@@ -11,10 +11,22 @@ Source: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/suba
A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features (steering, resume) are instead optional methods on [`SubagentRun`](#a-live-run-subagentrun) — the method's presence IS the capability, and TS narrowing is the discovery mechanism. A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features (steering, resume) are instead optional methods on [`SubagentRun`](#a-live-run-subagentrun) — the method's presence IS the capability, and TS narrowing is the discovery mechanism.
```ts type-equiv ```ts type-equiv
/**
* Which START-TIME features a provider supports. Checked by the service before delegating to
* {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks
* is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent
* degradation" rule). These static flags cover features needed before a run exists; runtime
* capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence
* is the capability.
*/
interface SubagentCapabilities { interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
readonly outputSchema: boolean readonly outputSchema: boolean
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
readonly depthLimit: boolean readonly depthLimit: boolean
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
readonly toolFilter: boolean readonly toolFilter: boolean
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
readonly persona: boolean readonly persona: boolean
} }
``` ```
@@ -24,14 +36,60 @@ interface SubagentCapabilities {
The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional output schema, depth, tool filter, and persona require matching capability flags. Unsupported schemas fail at start; in-process backends scope filters and personas to child creation and implement the supported object-rooted schema with a forced capture tool. The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional output schema, depth, tool filter, and persona require matching capability flags. Unsupported schemas fail at start; in-process backends scope filters and personas to child creation and implement the supported object-rooted schema with a forced capture tool.
```ts type-equiv ```ts type-equiv
/**
* What a caller asks for when starting a subagent. The tool layer builds this
* from the model's `{ description, prompt }` plus its own config; the service
* validates {@link SubagentCapabilities} against the named provider, then
* passes it to {@link SubagentProvider.start}.
*/
interface SubagentStartRequest { interface SubagentStartRequest {
/** The task/prompt for the child agent (a user message in the child session). */
readonly prompt: ContentBlock[] readonly prompt: ContentBlock[]
/**
* The spawning ("parent") agent — the one whose tool call started this
* subagent. REQUIRED: in-process backends read `parent.session.header` for
* the working directory, the `parentSession` lineage to stamp on the child,
* and the parent's delegation depth. Out-of-process backends (ACP) ignore it.
*/
readonly parent: Agent readonly parent: Agent
/**
* Cancellation signal from the spawning context (the tool's `exec.signal`).
* This is the canonical cancellation channel both before and after startup:
* a provider rejects `start()` after cleaning partial resources when it
* fires before publication, and cancels a published child when it fires
* afterward.
*/
readonly signal: AbortSignal readonly signal: AbortSignal
/** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions readonly agentOptions?: AgentOptions
/**
* Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects
* unsupported schemas or providers without the capability. Data must be plain host-realm JSON;
* a successful child returns the matching value as {@link SubagentResult.structured}.
*/
readonly outputSchema?: StructuredOutputSchema readonly outputSchema?: StructuredOutputSchema
/**
* Optional absolute delegation-depth cap for the child being started: its
* computed depth must be less than or equal to this non-negative safe
* integer. Requires {@link SubagentCapabilities.depthLimit}; rejected at
* start otherwise.
*/
readonly maxDepth?: number readonly maxDepth?: number
/**
* Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter};
* rejected at start otherwise. In-process backends apply it as a scoped
* `tools.restrict()` in the child's creation window: the named tools vanish
* from the child's prompt AND refuse to execute (one visibility), with loud
* unknown-name validation.
*/
readonly toolFilter?: ToolRestriction readonly toolFilter?: ToolRestriction
/**
* Optional per-child persona. Requires {@link SubagentCapabilities.persona};
* rejected at start otherwise. In-process backends register it as a scoped
* `deployment:persona` section on the child, SHADOWING the deployment's
* persona for this child alone — same template semantics as the deployment
* persona (strict `{{…}}` interpolation against the registered variables).
*/
readonly persona?: string readonly persona?: string
} }
``` ```
@@ -43,9 +101,21 @@ interface SubagentStartRequest {
The outcome of a run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. The outcome of a run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success.
```ts type-equiv ```ts type-equiv
/**
* The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}.
*/
interface SubagentResult { interface SubagentResult {
/** The child's final assistant output (the last assistant message's content). */
readonly output: ContentBlock[] readonly output: ContentBlock[]
/**
* The structured result after a requested `outputSchema` was successfully
* satisfied. Requesting a schema does not guarantee presence: a provider can
* end with `stopReason: 'error'` when the child fails or finishes without a
* valid capture. Shape is validated against the request schema by the
* provider; `unknown` here because the seam is schema-agnostic.
*/
readonly structured?: unknown readonly structured?: unknown
/** Why the run ended. A non-`completed` reason means `output` may be partial. */
readonly stopReason: SubagentStopReason readonly stopReason: SubagentStopReason
} }
``` ```
@@ -53,11 +123,22 @@ interface SubagentResult {
`SubagentStopReason` is a [merge-extensible derived union](core.md#the-map--derived-union-pattern) — a backend may add variants, so consumers branch on the known cases and treat an unknown terminal reason as a failure: `SubagentStopReason` is a [merge-extensible derived union](core.md#the-map--derived-union-pattern) — a backend may add variants, so consumers branch on the known cases and treat an unknown terminal reason as a failure:
```ts type-equiv ```ts type-equiv
/**
* Why a subagent run ended. Merge-extensible (a backend may add variants);
* consumers branch on the known cases and fall through `default`. The known
* cases mirror the harness turn-end vocabulary so the tool layer can map a
* non-`completed` result to an `isError` tool result.
*/
interface SubagentStopReasonMap { interface SubagentStopReasonMap {
/** The child finished its turn normally. */
completed: 'completed' completed: 'completed'
/** The run was cancelled by its request signal or by disposal. */
aborted: 'aborted' aborted: 'aborted'
/** The child failed (model error, transport error). */
error: 'error' error: 'error'
/** The child hit its token ceiling before finishing. */
'max-tokens': 'max-tokens' 'max-tokens': 'max-tokens'
/** The child declined the task. */
refusal: 'refusal' refusal: 'refusal'
} }
``` ```
@@ -67,12 +148,47 @@ interface SubagentStopReasonMap {
`SubagentRun` is the consumer-owned handle for a ready child. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. Optional `sendMessage` and `resume` methods advertise their runtime capabilities by presence. `SubagentRun` is the consumer-owned handle for a ready child. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. Optional `sendMessage` and `resume` methods advertise their runtime capabilities by presence.
```ts type-equiv ```ts type-equiv
/**
* Child handle returned only after readiness. Consumers await {@link result} and must always
* {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime
* capability discovery; narrow their presence before calling.
*/
interface SubagentRun { interface SubagentRun {
/**
* Parent-scoped run id. For a local run, this MUST equal the published child
* session id, whose `parentSession` records `request.parent.session.id`; a
* remote provider mints an id unique in the parent namespace.
*/
readonly id: SessionId readonly id: SessionId
/**
* The exact published in-process child, or `undefined` for a remote run.
* When present, its id is {@link id}; the provider retains no ownership
* implication beyond the run's ordinary {@link dispose} contract.
*/
readonly localAgent: Agent | undefined readonly localAgent: Agent | undefined
/**
* Resolves with the child's terminal {@link SubagentResult} when the run
* settles. Does NOT reject on a child-level failure — a model/transport
* failure resolves with `stopReason: 'error'` so the consumer maps it to an
* `isError` tool result. Rejects only on an infrastructure fault the seam
* cannot represent as a stop reason.
*/
readonly result: Promise<SubagentResult> readonly result: Promise<SubagentResult>
/**
* Cancel remaining work, reach child quiescence, and release the run's
* resources (in-process: dispose the owned agent and remove its session;
* ACP: kill and reap the subprocess). Idempotent.
*/
dispose(): Promise<void> dispose(): Promise<void>
/**
* OPTIONAL (steering capability): send additional content to the running
* child between steps. Present only on providers that support live steering.
*/
sendMessage?(content: ContentBlock[]): void sendMessage?(content: ContentBlock[]): void
/**
* OPTIONAL (resume capability): send a follow-up task to a settled child,
* continuing its session, and return a fresh run for the continuation.
*/
resume?(content: ContentBlock[]): Promise<SubagentRun> resume?(content: ContentBlock[]): Promise<SubagentRun>
} }
``` ```
@@ -84,10 +200,33 @@ A local run MUST publish an ordinary child agent/session before `start()` fulfil
Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority. Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority.
```ts type-equiv ```ts type-equiv
/**
* A subagent backend: one transport for running a child agent (in-process
* spawn/fork, ACP to another process, …). Implementations register under a
* unique name via {@link SubagentService.registerProvider}; multiple providers
* coexist in one context (unlike the single-implementation bash seam). The
* Providers are trusted same-process implementations; callers treat their
* descriptors and returned values as borrowed immutable data.
*/
interface SubagentProvider { interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
readonly name: string readonly name: string
/** The start-time features this provider supports (see {@link SubagentCapabilities}). */
readonly capabilities: SubagentCapabilities readonly capabilities: SubagentCapabilities
/**
* Whether the child sees the parent's completed-turn prefix. This is descriptive, not a
* service-validated start capability: the model-facing tool derives truthful wording from it.
* It says nothing about tool registration, injected services, or authority inheritance.
*/
readonly inheritsParentContext: boolean readonly inheritsParentContext: boolean
/**
* Establish a child and return its handle only after publication. The
* service has already validated that every requested start-time capability
* is supported, so an implementation may assume e.g. `request.maxDepth` is
* honorable when present. If setup fails or `request.signal` aborts before
* fulfillment, the provider owns and cleans all partial resources before this
* promise rejects. Ownership transfers to the caller only on fulfillment.
*/
start(request: SubagentStartRequest): Promise<SubagentRun> start(request: SubagentStartRequest): Promise<SubagentRun>
} }
``` ```

View File

@@ -9,7 +9,12 @@ Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-
`AssembleContext` identifies the scope layer one assembly resolves. It is merge-extensible: `dsh-agent` adds the optional live `agent` field, and `assembleContextFor(agent)` sets that field and `scope` together. `AssembleContext` identifies the scope layer one assembly resolves. It is merge-extensible: `dsh-agent` adds the optional live `agent` field, and `assembleContextFor(agent)` sets that field and `scope` together.
```ts type-equiv ```ts type-equiv
/** Merge-extensible context for one prompt assembly. */
interface AssembleContext { interface AssembleContext {
/**
* Scope whose providers and waterfall listeners participate. When absent,
* only global providers and subject-less listeners participate.
*/
scope?: ScopeKey scope?: ScopeKey
} }
``` ```
@@ -19,8 +24,11 @@ interface AssembleContext {
`ToolProviderResult.schemas` is the model-visible set for the current assembly. `knownNames` is the provider's pre-restriction name universe used to distinguish a configured-name typo from a known tool that is deliberately hidden in this scope. `ToolProviderResult.schemas` is the model-visible set for the current assembly. `knownNames` is the provider's pre-restriction name universe used to distinguish a configured-name typo from a known tool that is deliberately hidden in this scope.
```ts type-equiv ```ts type-equiv
/** Tool schemas visible in one assembly and their pre-restriction name set. */
interface ToolProviderResult { interface ToolProviderResult {
/** The schemas this provider contributes to THIS assembly. */
readonly schemas: readonly ToolSchema[] readonly schemas: readonly ToolSchema[]
/** The pre-restriction name universe for config validation (defaults to `schemas`' names). */
readonly knownNames?: readonly string[] readonly knownNames?: readonly string[]
} }
``` ```
@@ -30,9 +38,21 @@ interface ToolProviderResult {
`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. `PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context.
```ts type-equiv ```ts type-equiv
/** One contributed section of the system prompt (registry input). */
interface PromptSection { interface PromptSection {
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */
readonly name: string readonly name: string
/**
* Sections are concatenated in ascending order. Convention: `-100` is the
* harness identity, `0` the deployment persona, tool guidance uses 100199;
* other negative orders also render before the persona.
*/
readonly order: number readonly order: number
/**
* Static text or a provider evaluated at each assembly with that assembly's
* {@link AssembleContext}. The text may reference `{{variable}}`s — they are
* interpolated later, by {@link renderPrompt}.
*/
readonly text: string | ((context: AssembleContext) => string) readonly text: string | ((context: AssembleContext) => string)
} }
``` ```

View File

@@ -7,6 +7,10 @@ Types shared by long-running producers, `ctx.tasks`, and task control surfaces.
`TaskId` is a [branded id](core.md#branded-ids) generated as `<kind>-N`. Access control relies on owner authorization, not id secrecy. `TaskKind` derives from a merge-extensible map; the registry treats kinds as opaque id namespaces. `TaskId` is a [branded id](core.md#branded-ids) generated as `<kind>-N`. Access control relies on owner authorization, not id secrecy. `TaskKind` derives from a merge-extensible map; the registry treats kinds as opaque id namespaces.
```ts type-equiv ```ts type-equiv
/**
* Producer-defined task kinds. Plugins extend this map by declaration merging;
* the registry treats every value as an opaque id namespace.
*/
interface TaskKindMap { interface TaskKindMap {
bash: 'bash' bash: 'bash'
subagent: 'subagent' subagent: 'subagent'
@@ -20,6 +24,11 @@ interface TaskKindMap {
`TaskStart` declares identity and a starter. The runtime finishes preflight before calling `run()` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. `TaskStart` declares identity and a starter. The runtime finishes preflight before calling `run()` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state.
```ts type-equiv ```ts type-equiv
/**
* Producer declaration passed to {@link TaskService.start}. The runtime
* preflights access and cleanup before invoking {@link run}; the producer owns
* execution resources while the runtime owns identity and lifecycle state.
*/
interface TaskStart { interface TaskStart {
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */ /** Producer kind — also the id prefix (`bash`, `subagent`, …). */
kind: TaskKind kind: TaskKind
@@ -44,6 +53,7 @@ interface TaskStart {
`TaskHooks.done` is the quiescence boundary. Optional `readOutput` distinguishes consuming stream tasks from final-output-only tasks. `TaskHooks.done` is the quiescence boundary. Optional `readOutput` distinguishes consuming stream tasks from final-output-only tasks.
```ts type-equiv ```ts type-equiv
/** Hooks through which the runtime controls and observes producer work. */
interface TaskHooks { interface TaskHooks {
/** /**
* Request termination. Must be synchronous, idempotent, and eventually settle * Request termination. Must be synchronous, idempotent, and eventually settle
@@ -67,6 +77,7 @@ interface TaskHooks {
``` ```
```ts type-equiv ```ts type-equiv
/** Terminal result supplied by a producer through {@link TaskHooks.done}. */
interface TaskOutcome { interface TaskOutcome {
/** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ /** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
status: 'completed' | 'killed' | 'failed' status: 'completed' | 'killed' | 'failed'
@@ -82,6 +93,10 @@ interface TaskOutcome {
Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another surface has delivered or committed to deliver the terminal state. Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another surface has delivered or committed to deliver the terminal state.
```ts type-equiv ```ts type-equiv
/**
* A read-only projection of one task, safe to hand to listeners and tools —
* a fresh object per call, never live registry state.
*/
interface TaskSnapshot { interface TaskSnapshot {
/** The registry-issued id (`<kind>-N`). */ /** The registry-issued id (`<kind>-N`). */
id: TaskId id: TaskId
@@ -112,6 +127,7 @@ interface TaskSnapshot {
``` ```
```ts type-equiv ```ts type-equiv
/** Output and post-read state returned by {@link TaskService.read}. */
interface TaskRead { interface TaskRead {
/** /**
* Stream kinds: the consuming delta since the previous read. Final-output * Stream kinds: the consuming delta since the previous read. Final-output

View File

@@ -7,6 +7,7 @@ Source: [`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter
## `TokenMeasurement` ## `TokenMeasurement`
```ts type-equiv ```ts type-equiv
/** Detached immutable request-pressure and surface snapshot at one consumed log revision. */
interface TokenMeasurement { interface TokenMeasurement {
/** Number of durable events consumed; equal to the next unread event seq. */ /** Number of durable events consumed; equal to the next unread event seq. */
readonly logRevision: number readonly logRevision: number
@@ -28,6 +29,7 @@ interface TokenMeasurement {
## `TokenSurfaceNode` ## `TokenSurfaceNode`
```ts type-equiv ```ts type-equiv
/** One token-priced node in the current ordered session surface. */
interface TokenSurfaceNode { interface TokenSurfaceNode {
/** Durable sequence number of the surface event. */ /** Durable sequence number of the surface event. */
readonly seq: number readonly seq: number

View File

@@ -9,6 +9,7 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index
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. 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 ```ts type-equiv
/** A registered tool: its schema plus the execution function. */
interface ToolDefinition extends ToolSchema { interface ToolDefinition extends ToolSchema {
execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn> execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
/** /**
@@ -26,7 +27,9 @@ interface ToolDefinition extends ToolSchema {
* *
* Opted-in executions must not mutate parent-owned state. Shared state must * Opted-in executions must not mutate parent-owned state. Shared state must
* tolerate concurrent dispatch; recorder races are permitted only when they * tolerate concurrent dispatch; recorder races are permitted only when they
* commute or fail closed. See the parallel-tool-call RFC for the full contract. * 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. * @param args - parsed arguments; `defineTool` validates before calling.
* @returns Whether this call may join a parallel group. * @returns Whether this call may join a parallel group.
*/ */
@@ -61,6 +64,7 @@ Plugin authors write per-property specs with a boolean `required: true`, and a t
Source: [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) Source: [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts)
```ts type-equiv ```ts type-equiv
/** One schema-spec property entry. */
interface SchemaProp { interface SchemaProp {
type: SchemaType type: SchemaType
/** Per-property required flag (NOT the JSON Schema top-level required array). */ /** Per-property required flag (NOT the JSON Schema top-level required array). */
@@ -69,7 +73,10 @@ interface SchemaProp {
description?: string description?: string
/** Enum of allowed values (strings only). */ /** Enum of allowed values (strings only). */
enum?: string[] enum?: string[]
/** Default value. */ /**
* Model-visible JSON Schema default annotation. Validation does not apply it;
* dynamic tool mounts may supply it even though first-party definitions do not.
*/
default?: unknown default?: unknown
/** Nested properties for type: 'object'. */ /** Nested properties for type: 'object'. */
properties?: SchemaSpec properties?: SchemaSpec
@@ -79,12 +86,29 @@ interface SchemaProp {
``` ```
```ts type-equiv ```ts type-equiv
/**
* The author-facing parameter schema: a shallow map of property name to
* {@link SchemaProp}. Required-ness is a per-property boolean (`required:
* true`), not a separate array.
*/
type SchemaSpec = Record<string, SchemaProp> type SchemaSpec = Record<string, SchemaProp>
``` ```
`SchemaType` is the primitive union `'string' | 'number' | 'boolean' | 'object' | 'array'`. `InferArgs<S>` maps a `SchemaSpec` to the TS argument type — `required: true` props become required keys, everything else genuinely optional: `SchemaType` is the primitive union `'string' | 'number' | 'boolean' | 'object' | 'array'`. `InferArgs<S>` maps a `SchemaSpec` to the TS argument type — `required: true` props become required keys, everything else genuinely optional:
```ts type-equiv ```ts type-equiv
/**
* Infer the TS argument type for a complete {@link SchemaSpec}.
*
* Properties marked `required: true` are required keys; all others are
* genuinely optional keys (`?`), so callers may omit them entirely.
*
* Example:
* ```ts
* type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }>
* // → { path: string; limit?: number }
* ```
*/
type InferArgs<S extends SchemaSpec> = Simplify< type InferArgs<S extends SchemaSpec> = Simplify<
& { [K in RequiredKeys<S>]: InferPropValue<S[K]> } & { [K in RequiredKeys<S>]: InferPropValue<S[K]> }
& { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferPropValue<S[K]> } & { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferPropValue<S[K]> }
@@ -100,8 +124,14 @@ Registration is a trusted same-process contract. The registry borrows the typed
`ToolRestriction` applies only to the live deployment-global tool layer. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays scope-local tools. A deny-only filter admits later unlisted globals, while an allow-list excludes them. `ToolRestriction` applies only to the live deployment-global tool layer. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays scope-local tools. A deny-only filter admits later unlisted globals, while an allow-list excludes them.
```ts type-equiv ```ts type-equiv
/**
* Per-scope filter over global tools. Restrictions intersect and do not affect
* scoped registrations or the reserved Code Mode transport.
*/
interface ToolRestriction { interface ToolRestriction {
/** Global tool names that stay visible; everything else is removed. */
readonly allow?: readonly string[] readonly allow?: readonly string[]
/** Global tool names removed from visibility. */
readonly deny?: readonly string[] readonly deny?: readonly string[]
} }
``` ```
@@ -111,14 +141,20 @@ interface ToolRestriction {
`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). The outcome is a `ToolExecutionResult`. `ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). The outcome is a `ToolExecutionResult`.
```ts type-equiv ```ts type-equiv
/** Opaque call identity that permits correlation without exposing mutable execution state. */
type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
``` ```
```ts type-equiv ```ts type-equiv
/**
* Caller-supplied description of one tool call. {@link ToolRegistry.execute}
* adds the registry-owned token to form a pipeline {@link ToolExecution};
* callers do not choose that token.
*/
interface ToolExecutionInput { interface ToolExecutionInput {
readonly callId: CallId readonly callId: CallId
readonly name: string readonly name: string
/** Parsed JSON arguments (unknown — tools validate their own input). */ /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */
readonly arguments: unknown readonly arguments: unknown
/** The agent on whose behalf the call runs (set by the agent loop). */ /** The agent on whose behalf the call runs (set by the agent loop). */
readonly agent?: Agent readonly agent?: Agent
@@ -135,6 +171,12 @@ interface ToolExecutionInput {
A tool body receives the runtime extension. `deferContext()` is the composite-tool channel: it records nested-dispatch context without injecting inside the still-open outer call. A tool body receives the runtime extension. `deferContext()` is the composite-tool channel: it records nested-dispatch context without injecting inside the still-open outer call.
```ts type-equiv ```ts type-equiv
/**
* Runtime context handed to a tool implementation after the registry has
* accepted a {@link ToolExecution}. A composite tool uses
* {@link deferContext} to ferry context produced by nested dispatches back to
* the outer result; the loop appends it only after the outer `tool/result`.
*/
interface ToolRunContext extends ToolExecution { interface ToolRunContext extends ToolExecution {
/** /**
* Defer one nested-dispatch context until this tool's final result reaches * Defer one nested-dispatch context until this tool's final result reaches
@@ -148,12 +190,23 @@ 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: 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 ```ts type-equiv
/**
* Scheduling mode for one pending call. `parallel` may overlap with siblings;
* `exclusive` runs alone and forms an ordering barrier.
*/
type ToolExecutionMode = type ToolExecutionMode =
| { kind: 'parallel' } | { kind: 'parallel' }
| { kind: 'exclusive' } | { kind: 'exclusive' }
``` ```
```ts type-equiv ```ts type-equiv
/**
* One pending tool call inside the registry pipeline. Parsed arguments cross
* one lossless-JSON materialization boundary before policy and are deep-frozen;
* call identity and the registry-assigned {@link token} are readonly. An
* around-dispatch wrapper may set, replace, or remove `signal`. The registry
* freezes the complete object before `tools/result` observers run.
*/
interface ToolExecution extends ToolExecutionInput { interface ToolExecution extends ToolExecutionInput {
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
readonly token: ToolExecutionToken readonly token: ToolExecutionToken
@@ -165,10 +218,19 @@ interface ToolExecution extends ToolExecutionInput {
A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it.
```ts type-equiv ```ts type-equiv
/**
* A monotonic execution guard evaluated after every `tools/pre-execute`
* listener and before the tool body. Returning a reason denies the call;
* returning `undefined` leaves it unchanged. Because guards have no allow
* result, listener ordering cannot turn a denial back into permission.
* @param execution - the identity-protected call after extensible pre-execute policy completed.
* @returns a final denial reason, or `undefined` to leave the call allowed.
*/
type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
``` ```
```ts type-equiv ```ts type-equiv
/** The outcome of one tool call. */
interface ToolExecutionResult { interface ToolExecutionResult {
content: ContentBlock[] content: ContentBlock[]
isError: boolean isError: boolean
@@ -179,14 +241,8 @@ interface ToolExecutionResult {
*/ */
error?: ToolErrorInfo error?: ToolErrorInfo
/** /**
* Extra model-facing contexts deferred by a composite tool or attached by * Model-facing context for the next request, separate from this tool result. The loop
* `tools/post-execute` listeners for the NEXT request. They are not part of * accepts it into the active-batch FIFO, then appends after recorded results even if interrupted.
* this call's `content`: the loop accepts them into the active-batch FIFO and
* appends them after every recorded `tool/result` when the batch settles, even
* when execution is interrupted. The array preserves each context's source,
* envelope, metadata, and production order. An accepted outer call keeps
* deferred contexts before decision contexts; a block retains only contexts
* supplied by the blocking decision.
*/ */
additionalContexts?: HookContext[] additionalContexts?: HookContext[]
/** /**
@@ -206,6 +262,12 @@ The registry materializes and freezes the final accepted result immediately befo
Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`: Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`:
```ts type-equiv ```ts type-equiv
/**
* Pre-dispatch decision. `allow` runs the call; `deny` materializes an error;
* `ask` runs only after an approval service returns `allowed-once` and otherwise
* denies. Input rewriting is excluded because arguments are already logged and
* presented.
*/
type PreToolDecision = type PreToolDecision =
| { kind: 'allow' } | { kind: 'allow' }
| { kind: 'deny'; reason: string } | { kind: 'deny'; reason: string }
@@ -213,6 +275,10 @@ type PreToolDecision =
``` ```
```ts type-equiv ```ts type-equiv
/**
* Post-dispatch decision: accept or replace content, attach context for the next
* request, or block by turning corrective feedback into an error result.
*/
type PostToolDecision = type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] }
@@ -227,25 +293,41 @@ Post-policy may replace content; a block becomes an `isError` result containing
The vocabulary a caller uses to demand a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`, [subagent.md](subagent.md#the-start-request)) or a workflow `agent()` call. It is deliberately NOT full JSON Schema: the schema travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated client-side by `validateStructuredValue` — so every accepted keyword must be one the validator actually enforces, and `assertSupportedOutputSchema` rejects anything else loud (`OutputSchemaError`, listing every violation). Both walkers reason over own enumerable properties only (JSON carries nothing else) and reject non-plain objects (`Date`, `Map`) that would serialize lossily. The vocabulary a caller uses to demand a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`, [subagent.md](subagent.md#the-start-request)) or a workflow `agent()` call. It is deliberately NOT full JSON Schema: the schema travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated client-side by `validateStructuredValue` — so every accepted keyword must be one the validator actually enforces, and `assertSupportedOutputSchema` rejects anything else loud (`OutputSchemaError`, listing every violation). Both walkers reason over own enumerable properties only (JSON carries nothing else) and reject non-plain objects (`Date`, `Map`) that would serialize lossily.
```ts type-equiv ```ts type-equiv
/** The scalar values `enum`/`const` may carry (finite numbers only). */
type StructuredScalar = string | number | boolean | null type StructuredScalar = string | number | boolean | null
``` ```
```ts type-equiv ```ts type-equiv
/** The `type` keywords the subset accepts. */
type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
``` ```
```ts type-equiv ```ts type-equiv
/**
* One node of the structured-output schema subset. Recursive via `properties`
* and `items`; see the module doc for the exact keyword semantics.
*/
interface StructuredSchemaNode { interface StructuredSchemaNode {
type: StructuredSchemaType type: StructuredSchemaType
/** Nested property schemas (`type: 'object'` only). */
properties?: Record<string, StructuredSchemaNode> properties?: Record<string, StructuredSchemaNode>
/** Required property names; each must appear in `properties`. */
required?: string[] required?: string[]
/** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */
additionalProperties?: boolean additionalProperties?: boolean
/** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */
items?: StructuredSchemaNode items?: StructuredSchemaNode
/** Allowed values (scalar types only). */
enum?: StructuredScalar[] enum?: StructuredScalar[]
/** The single allowed value (scalar types only). */
const?: StructuredScalar const?: StructuredScalar
/** Annotation, ignored for validation. */
description?: string description?: string
/** Annotation, ignored for validation. */
title?: string title?: string
/** Annotation, ignored for validation (must still be JSON data). */
default?: unknown default?: unknown
/** Annotation, ignored for validation (must still be JSON data). */
examples?: unknown examples?: unknown
} }
``` ```
@@ -253,6 +335,7 @@ interface StructuredSchemaNode {
A schema is an object-rooted node (`enum`/`const` are scalar-only; `description`/`title`/`default`/`examples` are annotations, allowed and ignored but still required to be JSON data — they ride the wire): A schema is an object-rooted node (`enum`/`const` are scalar-only; `description`/`title`/`default`/`examples` are annotations, allowed and ignored but still required to be JSON data — they ride the wire):
```ts type-equiv ```ts type-equiv
/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */
type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
``` ```

View File

@@ -9,6 +9,7 @@ Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-int
`AskUserQuestionOption` is the selectable-choice shape. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text. `AskUserQuestionOption` is the selectable-choice shape. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text.
```ts type-equiv ```ts type-equiv
/** One selectable answer offered to the user. */
interface AskUserQuestionOption { interface AskUserQuestionOption {
/** User-facing label. */ /** User-facing label. */
label: string label: string
@@ -22,6 +23,7 @@ interface AskUserQuestionOption {
`AskUserQuestionItem` is one question in a request. The model supplies a stable `id`, which is echoed back with the answer so batched questions remain routable. `AskUserQuestionItem` is one question in a request. The model supplies a stable `id`, which is echoed back with the answer so batched questions remain routable.
```ts type-equiv ```ts type-equiv
/** One question in an ask_user_question request. */
interface AskUserQuestionItem { interface AskUserQuestionItem {
/** Stable model-provided question id, echoed in the answer. */ /** Stable model-provided question id, echoed in the answer. */
id: string id: string
@@ -41,6 +43,7 @@ interface AskUserQuestionItem {
`AskUserQuestionRequest` is the cross-package request. `questions` is an array so a UI can present related prompts in one flow while preserving a stable id per answer. `AskUserQuestionRequest` is the cross-package request. `questions` is an array so a UI can present related prompts in one flow while preserving a stable id per answer.
```ts type-equiv ```ts type-equiv
/** Request for a human answer. */
interface AskUserQuestionRequest { interface AskUserQuestionRequest {
/** Questions to display. */ /** Questions to display. */
questions: AskUserQuestionItem[] questions: AskUserQuestionItem[]
@@ -56,6 +59,7 @@ interface AskUserQuestionRequest {
Providers return one answer per answered question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. Providers return one answer per answered question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices.
```ts type-equiv ```ts type-equiv
/** Answer to one question. */
interface AskUserQuestionAnswerItem { interface AskUserQuestionAnswerItem {
/** The answered question id. */ /** The answered question id. */
id: string id: string
@@ -67,6 +71,7 @@ interface AskUserQuestionAnswerItem {
``` ```
```ts type-equiv ```ts type-equiv
/** The human's answer. */
interface AskUserQuestionAnswer { interface AskUserQuestionAnswer {
/** Structured answers keyed by question id. */ /** Structured answers keyed by question id. */
answers: AskUserQuestionAnswerItem[] answers: AskUserQuestionAnswerItem[]
@@ -78,6 +83,7 @@ interface AskUserQuestionAnswer {
Only one provider may be active in a context. Provider registration is effect-bound so HMR/disposal removes the active UI. Only one provider may be active in a context. Provider registration is effect-bound so HMR/disposal removes the active UI.
```ts type-equiv ```ts type-equiv
/** UI-side provider for user questions. */
interface UserInteractionProvider { interface UserInteractionProvider {
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
} }
@@ -88,6 +94,7 @@ interface UserInteractionProvider {
`UserInteractionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `ASK_ABORTED`, or ACP-side cancellation. `UserInteractionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `ASK_ABORTED`, or ACP-side cancellation.
```ts type-equiv ```ts type-equiv
/** Stable error taxonomy for user-interaction failures. */
class UserInteractionError extends HarnessError { class UserInteractionError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) { constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options) super(message, code, options)

View File

@@ -13,20 +13,37 @@ Search and fetch share no request schema and no business logic, but they are del
The model-facing tool argument is just a `query`; `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `searchMaxResults` config, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`. The model-facing tool argument is just a `query`; `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `searchMaxResults` config, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`.
```ts type-equiv ```ts type-equiv
/**
* What one search-capable backend can return. The model-facing argument is just
* a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged
* and enforced on the way back by the seam (see {@link WebSearchResult}).
*/
interface WebSearchRequest { interface WebSearchRequest {
readonly query: string readonly query: string
/** /**
* Upper bound on returned sources; the seam truncates to it. Omitted = no * Upper bound on returned sources; the seam truncates to it. Omitted = no
* bound. `dsh-tool-web` always sets it. * bound. `dsh-tool-web` always sets it. A provider whose API supports a
* result-count control (Exa's `numResults`) should apply it at the request
* layer as a cost/latency optimization; the seam enforces the bound
* regardless.
*/ */
readonly maxResults?: number readonly maxResults?: number
} }
``` ```
```ts type-equiv ```ts type-equiv
/**
* Normalized search outcome. `content` is optional provider-generated answer
* text or summary (Exa returns none; Perplexity returns a generated answer).
* `sources[]` is the portable citation surface. `truncated` is set by the seam
* when it cut `sources[]` down to `maxResults`.
*/
interface WebSearchResult { interface WebSearchResult {
/** Optional provider-generated answer text, search context, or summary. */
readonly content?: string readonly content?: string
/** Citeable sources, already truncated to the request's `maxResults`. */
readonly sources: readonly WebSearchSource[] readonly sources: readonly WebSearchSource[]
/** True when the seam dropped sources to honor `maxResults`. */
readonly truncated: boolean readonly truncated: boolean
} }
``` ```
@@ -34,10 +51,17 @@ interface WebSearchResult {
`content` is optional provider-generated answer text (Exa and DeepSeek return none; Perplexity returns a generated answer). `sources[]` is the portable citation surface. A source always has a `url`; `title`/`snippet`/`publishedAt` are optional because not every provider returns them — Perplexity citations may be URL-only, and forcing adapters to invent the rest would make the seam lie. `dsh-tool-web` renders `title ?? hostname(url)`. `content` is optional provider-generated answer text (Exa and DeepSeek return none; Perplexity returns a generated answer). `sources[]` is the portable citation surface. A source always has a `url`; `title`/`snippet`/`publishedAt` are optional because not every provider returns them — Perplexity citations may be URL-only, and forcing adapters to invent the rest would make the seam lie. `dsh-tool-web` renders `title ?? hostname(url)`.
```ts type-equiv ```ts type-equiv
/**
* One citeable source. A source always has a URL; `title`, `snippet`, and
* `publishedAt` are optional because not every provider returns them — forcing
* adapters to invent them would make the seam lie (Perplexity citations may be
* URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display.
*/
interface WebSearchSource { interface WebSearchSource {
readonly url: string readonly url: string
readonly title?: string readonly title?: string
readonly snippet?: string readonly snippet?: string
/** Publication/crawl timestamp as a provider-supplied ISO-8601 string. */
readonly publishedAt?: string readonly publishedAt?: string
} }
``` ```
@@ -45,6 +69,12 @@ interface WebSearchSource {
## Fetch request and result ## Fetch request and result
```ts type-equiv ```ts type-equiv
/**
* What one fetch-capable backend is asked to retrieve. The request deliberately
* omits timeout, format, prompt, and extraction controls: cancellation is a
* direct execution argument, while presentation and higher-level LLM concerns
* belong outside safe retrieval.
*/
interface WebFetchRequest { interface WebFetchRequest {
readonly url: string readonly url: string
} }
@@ -53,10 +83,20 @@ interface WebFetchRequest {
HTTP status is part of the fetched resource state, not automatically a failure: a successful network fetch of a `404`/`500` returns a `WebFetchResult` with the status code and a bounded decoded body. `url` is the final URL after allowed redirects. `WebError` is reserved for failures to safely retrieve or represent the resource. HTTP status is part of the fetched resource state, not automatically a failure: a successful network fetch of a `404`/`500` returns a `WebFetchResult` with the status code and a bounded decoded body. `url` is the final URL after allowed redirects. `WebError` is reserved for failures to safely retrieve or represent the resource.
```ts type-equiv ```ts type-equiv
/**
* Normalized fetch outcome. A successful network fetch of a non-2xx response is
* a result, not an error: the status code is part of the fetched resource
* state. {@link WebError} is reserved for failures to safely retrieve or
* represent the resource.
*/
interface WebFetchResult { interface WebFetchResult {
/** The final URL after allowed redirects (the request URL is in the request). */
readonly url: string readonly url: string
/** HTTP status code of the fetched response. */
readonly statusCode: number readonly statusCode: number
/** Decoded body, classified by content kind. */
readonly body: WebFetchBody readonly body: WebFetchBody
/** True when the provider capped the decoded body. */
readonly truncated: boolean readonly truncated: boolean
} }
``` ```
@@ -64,6 +104,15 @@ interface WebFetchResult {
`WebFetchBody` is a **closed** discriminated union owned by `dsh-web` (not a merge-extensible map): the provider decodes the kind and `dsh-tool-web` renders it, so a new kind is a coordinated change across known packages, not a plugin extension. Consumers `switch` on `kind` ending in `default: assertNever(...)`, so adding a kind breaks compilation at every consumer until handled. Each arm stays its own object literal even where fields coincide today, leaving room for arm-specific fields later (a future `pdf` body's `pageCount`). `WebFetchBody` is a **closed** discriminated union owned by `dsh-web` (not a merge-extensible map): the provider decodes the kind and `dsh-tool-web` renders it, so a new kind is a coordinated change across known packages, not a plugin extension. Consumers `switch` on `kind` ending in `default: assertNever(...)`, so adding a kind breaks compilation at every consumer until handled. Each arm stays its own object literal even where fields coincide today, leaving room for arm-specific fields later (a future `pdf` body's `pageCount`).
```ts type-equiv ```ts type-equiv
/**
* The decoded body of a fetched resource. A CLOSED discriminated union owned by
* `dsh-web`: the provider decodes the kind and `dsh-tool-web` renders it, so a
* new kind is a coordinated change across known packages, not a plugin
* extension. Consumers `switch` on `kind` ending in `default: assertNever(...)`
* so adding a kind breaks compilation at every consumer until handled. Each arm
* stays its own object literal even where fields coincide today, leaving room
* for arm-specific fields later (a `pdf` body's `pageCount`).
*/
type WebFetchBody = type WebFetchBody =
| { readonly kind: 'html'; readonly content: string } | { readonly kind: 'html'; readonly content: string }
| { readonly kind: 'text'; readonly content: string } | { readonly kind: 'text'; readonly content: string }

View File

@@ -11,11 +11,24 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work
What a caller asks for when starting a run. The tool layer builds this from the model's `{ script, meta, args }` call plus the calling agent; `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). What a caller asks for when starting a run. The tool layer builds this from the model's `{ script, meta, args }` call plus the calling agent; `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)).
```ts type-equiv ```ts type-equiv
/**
* What a caller asks for when starting a workflow run. `meta` and `args` are
* plain JSON DATA by the seam contract (the tool builds both from the model's
* schema-validated call; the engine validates `meta`'s shape and rejects loud
* before anything runs) — an engine never evaluates script text to obtain
* them. `parent` is REQUIRED — every `agent()` the script spawns is
* attributed to it (cwd, lineage, depth flow through the subagent seam).
*/
interface WorkflowStartRequest { interface WorkflowStartRequest {
/** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */
script: string script: string
/** The workflow's identity block, as plain JSON data (shape-validated by the engine). */
meta: WorkflowMeta meta: WorkflowMeta
/** Optional input exposed verbatim to the script as the `args` global. */
args?: unknown args?: unknown
/** The agent on whose behalf the run executes (parent of every child). */
parent: Agent parent: Agent
/** Cancels the run when aborted (the tool's `exec.signal`). */
signal?: AbortSignal signal?: AbortSignal
} }
``` ```
@@ -25,10 +38,21 @@ interface WorkflowStartRequest {
The identity block carried as data on the start request (the tool's `meta` parameter; the field vocabulary matches the Claude Code dynamic-workflows meta block). `phases` is progress vocabulary only: `phase()` calls match titles for observers; no execution structure is implied. The identity block carried as data on the start request (the tool's `meta` parameter; the field vocabulary matches the Claude Code dynamic-workflows meta block). `phases` is progress vocabulary only: `phase()` calls match titles for observers; no execution structure is implied.
```ts type-equiv ```ts type-equiv
/**
* The script's identity block, provided as plain JSON data alongside the
* script body (the model-facing tool carries it as its `meta` parameter) and
* validated by the engine before the body runs. `name`/`description` are
* required; the rest is optional annotation. The field vocabulary matches the
* Claude Code dynamic-workflows meta block.
*/
interface WorkflowMeta { interface WorkflowMeta {
/** Short kebab-case workflow name (display + persistence key). */
name: string name: string
/** One-line description of what the workflow does. */
description: string description: string
/** Optional guidance on when this workflow applies (shown in listings). */
whenToUse?: string whenToUse?: string
/** Optional phase declarations matched by `phase()` calls. */
phases?: WorkflowPhase[] phases?: WorkflowPhase[]
} }
``` ```
@@ -38,10 +62,27 @@ interface WorkflowMeta {
The outcome of one run, resolved by `WorkflowRun.result`. `value` is the script's materialized return value — plain host-realm JSON data (`null` when the script returned nothing) — meaningful only for `completed`. `stopReason` is a CLOSED union (engine-owned; consumers may exhaust it): `completed` | `cancelled` | `error`. A non-`completed` reason carries the failure in `error`, and the consumer maps it to an `isError` tool result rather than reporting partial output as success. The outcome of one run, resolved by `WorkflowRun.result`. `value` is the script's materialized return value — plain host-realm JSON data (`null` when the script returned nothing) — meaningful only for `completed`. `stopReason` is a CLOSED union (engine-owned; consumers may exhaust it): `completed` | `cancelled` | `error`. A non-`completed` reason carries the failure in `error`, and the consumer maps it to an `isError` tool result rather than reporting partial output as success.
```ts type-equiv ```ts type-equiv
/**
* The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is
* the script's materialized return value (plain host-realm JSON data; `null`
* when the script returned `undefined`) — meaningful only for `completed`.
* A non-`completed` reason carries the failure in `error`; the consumer maps
* it to an `isError` tool result rather than reporting partial output.
*/
interface WorkflowResult { interface WorkflowResult {
/** The script's return value (host JSON data; `null` for no return). */
value: unknown value: unknown
/** Why the run settled. */
stopReason: WorkflowStopReason stopReason: WorkflowStopReason
/** The failure message (present iff `stopReason` is not `completed`). */
error?: string error?: string
/**
* How many `agent()` calls the run accepted over its whole lifetime. On a
* graceful settlement this is the script-side count (calls still queued for
* a concurrency slot included); on a termination path (grace force-settle,
* worker death) it degrades to the host-observed count — calls queued
* inside a terminated script are unknowable then.
*/
agentsStarted: number agentsStarted: number
} }
``` ```
@@ -51,11 +92,20 @@ interface WorkflowResult {
The handle the consumer holds while a script executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path. `result` does NOT reject — a script failure resolves with `stopReason: 'error'` — and once the run is cancelled it SETTLES within the engine's bounded grace even if the script itself never settles (the engine force-settles `cancelled`; the worker-thread engine then terminates the script's worker), so a consumer awaiting `result` is never wedged past a cancellation. `dispose()` = cancel + that bounded settle + child quiescence; it never hangs on a stuck script. The handle the consumer holds while a script executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path. `result` does NOT reject — a script failure resolves with `stopReason: 'error'` — and once the run is cancelled it SETTLES within the engine's bounded grace even if the script itself never settles (the engine force-settles `cancelled`; the worker-thread engine then terminates the script's worker), so a consumer awaiting `result` is never wedged past a cancellation. `dispose()` = cancel + that bounded settle + child quiescence; it never hangs on a stuck script.
```ts type-equiv ```ts type-equiv
/**
* Holder-owned live workflow. `result` never rejects and settles within the
* engine's cancellation grace; failures resolve through `stopReason`. Consumers
* may cancel and must call idempotent `dispose()` on every path to await bounded
* script settlement and child quiescence.
*/
interface WorkflowRun { interface WorkflowRun {
readonly id: WorkflowRunId readonly id: WorkflowRunId
/** The validated meta block (available before the body runs). */
readonly meta: WorkflowMeta readonly meta: WorkflowMeta
readonly result: Promise<WorkflowResult> readonly result: Promise<WorkflowResult>
/** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */
cancel(reason?: string): void cancel(reason?: string): void
/** Cancel + bounded-grace settle; safe to call on every path (idempotent). */
dispose(): Promise<void> dispose(): Promise<void>
} }
``` ```

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
development.md: 79caf482d0d104ed170a57812dd4f34e2329de8b development.md: 37811f7215001fc371ac4943fe109dd5512ea8b0
development.zh.md: 75781932acedabd2e3593a89aed8360a9a18ca5b development.zh.md: 5036e3e75516fcaf063675fc9ab4e63c1fca851a

View File

@@ -145,13 +145,13 @@ Pick the tag that matches the urgency so anyone scanning the code can tell a rel
## Documenting types verbatim (`ts type-equiv`) ## Documenting types verbatim (`ts type-equiv`)
The [core data structures](core-data-structures/core.md) docs paste real type definitions so a reader sees the exact shape. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: The [core data structures](core-data-structures/core.md) docs paste real type declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:
```json ```json
{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }
``` ```
`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration from source via the TypeScript parser and asserts the block matches it (whitespace- and comment-insensitive, so a doc block may show a clean definition and the prose can carry the semantics). It also enforces a 1:1 correspondence: every `ts type-equiv` block has exactly one manifest entry and vice-versa, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips `ts type-equiv` blocks (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented type, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. `pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence: every `ts type-equiv` block has exactly one manifest entry and vice-versa, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips `ts type-equiv` blocks (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change.
## Architecture context ## Architecture context

View File

@@ -145,13 +145,13 @@ pnpm run demo:acp
## 逐字记录类型(`ts type-equiv` ## 逐字记录类型(`ts type-equiv`
[核心数据结构](core-data-structures/core.md)文档粘贴真实类型定义,让读者看到确切形状。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: [核心数据结构](core-data-structures/core.md)文档会把真实类型声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:
```json ```json
{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }
``` ```
`pnpm run verify-type-equiv``doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明,并断言文档块与之一致(对空白和注释不敏感,因此文档块可以展示干净的定义,语义由行文承载)。它还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然;因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个记录的类型时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。 `pnpm run verify-type-equiv``doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc并断言代码块同时匹配两者。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc包括成员文档让读者同时看到源码契约和确切形状。该门禁还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然;因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。
## 架构上下文 ## 架构上下文

View File

@@ -10,7 +10,7 @@ So the work had two intertwined questions: **what belongs in such a catalog** (t
## Decision ## Decision
A new `docs/core-data-structures/` folder catalogs the vocabulary, with a new `verify-type-equiv` doc-sync gate that keeps every pasted type definition byte-identical to its source. A new `docs/core-data-structures/` folder catalogs the vocabulary, with a new `verify-type-equiv` doc-sync gate that keeps every pasted type declaration and its JSDoc synchronized with source.
### What counts as "core" — the spine-vs-seam line ### What counts as "core" — the spine-vs-seam line
@@ -27,10 +27,10 @@ The rule that settled the remaining cases: ***the type you write, hold, or recei
### The `ts type-equiv` mechanism — literal AND drift-proof ### The `ts type-equiv` mechanism — literal AND drift-proof
The durability requirement was specific: the doc should show the **literal** current type definition (so a reader sees the real shape, not a paraphrase) **and** be mechanically guaranteed to match source. The repo already compiles fenced ` ```ts ` blocks (`doc-typecheck`), but a real typechecked block needs import noise and proves only *assignability*, not *byte-equality* — a renamed field with the same type would pass. So: The durability requirement was specific: the doc shows the **literal** current type declaration and original JSDoc (so a reader sees the real shape and source contract, not a paraphrase) **and** is mechanically guaranteed to match source. The repo already compiles fenced ` ```ts ` blocks (`doc-typecheck`), but a real typechecked block needs import noise and proves only *assignability* — a renamed field or changed JSDoc can pass. So:
- Type definitions are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. `doc-typecheck` recognizes the fence and skips it (a bare definition is not standalone-compilable), and **excludes it from the opt-out ratio** — it is a separately-checked category, not an unchecked sketch. - Type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. `doc-typecheck` recognizes the fence and skips it (a bare definition is not standalone-compilable), and **excludes it from the opt-out ratio** — it is a separately-checked category, not an unchecked sketch.
- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts a **verbatim source match** against the declared symbol — chosen over a compiled `_Check` assertion precisely because byte-equality, not assignability, is the property we want. - A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves.
- Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot. - Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot.
- Wired into `doc-sync`, so it runs in the same lefthook pre-push and CI paths as the other doc gates. - Wired into `doc-sync`, so it runs in the same lefthook pre-push and CI paths as the other doc gates.
@@ -41,7 +41,7 @@ The durability requirement was specific: the doc should show the **literal** cur
## Alternatives considered ## Alternatives considered
- **A flat dump of all cross-package vocabulary** — the `BashExecRequest` test case killed it: if seam vocabulary is "core", the catalog helps no one; the tiered spine-vs-seam structure won. - **A flat dump of all cross-package vocabulary** — the `BashExecRequest` test case killed it: if seam vocabulary is "core", the catalog helps no one; the tiered spine-vs-seam structure won.
- **A compiled `_Check` assignability assertion** instead of the verbatim source match — rejected because byte-equality, not assignability, is the property we want: a renamed field with the same type would pass assignability. - **A compiled `_Check` assignability assertion** instead of the source match — rejected because assignability does not preserve names or JSDoc: a renamed field with the same type or a changed contract comment would pass.
- **Provenance as directive comments in the prose** — rejected for the central manifest, whose enforced 1:1 correspondence means a block can never be silently unchecked and an entry can never rot. - **Provenance as directive comments in the prose** — rejected for the central manifest, whose enforced 1:1 correspondence means a block can never be silently unchecked and an entry can never rot.
## Verification lesson ## Verification lesson

View File

@@ -1,5 +1,5 @@
{ {
"comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source declaration and original JSDoc it must match. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.",
"entries": [ "entries": [
{ "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },

View File

@@ -1,7 +1,8 @@
/** /**
* Verify every `ts type-equiv` block against the source symbol named by the * Verify every `ts type-equiv` block against the source symbol named by the
* manifest. Blocks and entries have a one-to-one relationship; comparison * manifest. Blocks and entries have a one-to-one relationship; comparison
* ignores comments and whitespace but preserves declaration structure. * ignores whitespace and non-JSDoc comments but preserves declaration
* structure and every original JSDoc comment.
*/ */
import { globSync, readFileSync, existsSync } from 'node:fs' import { globSync, readFileSync, existsSync } from 'node:fs'
@@ -34,12 +35,8 @@ interface EquivBlock {
code: string code: string
} }
/** /** Normalize declaration structure independently of comments and whitespace. */
* Remove comments and normalize whitespace so prose-only edits do not drift function normalizeStructure(code: string): string {
* structural copies. This is intentionally not a general tokenizer: repo type
* declarations do not contain comment delimiters inside string literals.
*/
function normalize(code: string): string {
return code return code
.replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1') .replace(/(^|[^:])\/\/.*$/gm, '$1')
@@ -47,6 +44,15 @@ function normalize(code: string): string {
.trim() .trim()
} }
/**
* Extract normalized JSDoc comments in source order. Type declarations in this
* repository do not contain comment delimiters inside string literals.
*/
function normalizeJSDoc(code: string): string[] {
return [...code.matchAll(/\/\*\*[\s\S]*?\*\//g)]
.map(match => match[0].replace(/\s+/g, ' ').trim())
}
/** Strip source-only export modifiers. */ /** Strip source-only export modifiers. */
function stripExport(code: string): string { function stripExport(code: string): string {
return code.replace(/^export\s+(default\s+)?/, '') return code.replace(/^export\s+(default\s+)?/, '')
@@ -54,8 +60,14 @@ function stripExport(code: string): string {
/** Parse the declared symbol name from a type-equiv block body. */ /** Parse the declared symbol name from a type-equiv block body. */
function blockSymbol(code: string): string | null { function blockSymbol(code: string): string | null {
const m = /(?:export\s+(?:default\s+)?)?(?:abstract\s+)?(?:interface|type|class|enum)\s+([A-Za-z0-9_]+)/.exec(code) const sf = ts.createSourceFile('type-equiv.ts', code, ts.ScriptTarget.Latest, /* setParentNodes */ false, ts.ScriptKind.TS)
return m?.[1] ?? null for (const stmt of sf.statements) {
const named =
ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)
|| ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)
if (named && stmt.name) return stmt.name.text
}
return null
} }
/** Extract every ` ```ts type-equiv ` block from one Markdown file. */ /** Extract every ` ```ts type-equiv ` block from one Markdown file. */
@@ -88,11 +100,12 @@ function extractEquivBlocks(docRel: string): EquivBlock[] {
return blocks return blocks
} }
/** The declaration text of `symbol` in `sourceRel`, with `export` stripped, or /**
* The declaration text of `symbol` in `sourceRel`, with `export` stripped, or
* null when the symbol is not declared there. Uses the TS parser so it spans * null when the symbol is not declared there. Uses the TS parser so it spans
* interfaces, type aliases (including mapped/generic ones), classes, and enums * interfaces, type aliases (including mapped/generic ones), classes, and enums
* uniformly, and excludes the leading JSDoc (getStart skips leading trivia) * uniformly while including declaration and member JSDoc.
* while keeping inline member comments. */ */
function sourceDeclaration(sourceRel: string, symbol: string): string | null { function sourceDeclaration(sourceRel: string, symbol: string): string | null {
const abs = resolve(root, sourceRel) const abs = resolve(root, sourceRel)
const text = readFileSync(abs, 'utf8') const text = readFileSync(abs, 'utf8')
@@ -102,7 +115,13 @@ function sourceDeclaration(sourceRel: string, symbol: string): string | null {
ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)
|| ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt) || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)
if (named && stmt.name?.text === symbol) { if (named && stmt.name?.text === symbol) {
return stripExport(stmt.getText(sf)) const declarationStart = stmt.getStart(sf)
const jsDoc = ts.getJSDocCommentsAndTags(stmt)
.filter(ts.isJSDoc)
.map(doc => text.slice(doc.pos, doc.end))
.join('\n')
const declaration = stripExport(text.slice(declarationStart, stmt.getEnd()))
return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
} }
} }
return null return null
@@ -178,11 +197,18 @@ for (const e of entries) {
errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`) errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`)
continue continue
} }
if (normalize(decl) !== normalize(stripExport(b.code))) { const doc = stripExport(b.code)
const sourceStructure = normalizeStructure(decl)
const docStructure = normalizeStructure(doc)
const sourceJSDoc = normalizeJSDoc(decl)
const docJSDoc = normalizeJSDoc(doc)
if (sourceStructure !== docStructure || JSON.stringify(sourceJSDoc) !== JSON.stringify(docJSDoc)) {
errors.push( errors.push(
`DRIFT: ${e.doc}:${b.line} — type-equiv block for ${e.symbol} does not match ${e.source}.\n` `DRIFT: ${e.doc}:${b.line} — type-equiv block for ${e.symbol} does not match ${e.source}.\n`
+ ` source: ${normalize(decl)}\n` + ` source structure: ${sourceStructure}\n`
+ ` doc: ${normalize(stripExport(b.code))}`, + ` doc structure: ${docStructure}\n`
+ ` source JSDoc: ${JSON.stringify(sourceJSDoc)}\n`
+ ` doc JSDoc: ${JSON.stringify(docJSDoc)}`,
) )
continue continue
} }
@@ -190,7 +216,7 @@ for (const e of entries) {
} }
if (errors.length === 0) { if (errors.length === 0) {
console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source (1:1 with manifest).`) console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source structure and JSDoc (1:1 with manifest).`)
process.exit(0) process.exit(0)
} }