2026-06-20 16:24:56 +08:00
# Core Data Structures
2026-07-22 03:40:46 -07:00
English | [中文 ](core.zh.md )
2026-06-20 16:24:56 +08:00
This folder catalogs the **data structures** of the DeepSeek Harness — what each core type represents, its literal shape, and where the full detail lives. It complements [architecture.md ](../architecture.md ), which describes *behavior* (the service map, the session/turn/step lifecycle, the event taxonomy); this page describes the *vocabulary* that behavior moves around.
## What counts as "core"
The harness is a microkernel: a tiny core plus many plugins. Most types belong to one plugin or one capability. A handful, though, are the **spine** — the language the agent loop and its events traffic in on *every* turn, no matter which optional plugins are loaded. Those are "core".
Precisely, a data structure is **core** if either:
1. it flows through the agent-loop spine — the loop holds it, derives it, streams it, or logs it on every turn (a `Message` , a `StreamChunk` , a `SessionEvent` , the `Agent` handle itself), independent of which plugins are present; **or**
2. it is the single headline type a plugin author writes against a pipeline — `ToolDefinition` (what every tool *is* ).
2026-07-03 02:04:03 +08:00
Everything else is documented on a **sub-page** , not here. The rule that draws the line: *the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.* So `ToolDefinition` is core, but the `SchemaSpec` /`InferArgs` DSL that types it, the `ToolCallView` /`ToolResultView` render-intent vocabulary that renders it, and the `SessionPersistence` seam that stores the event log are not — they live on the sub-pages below.
2026-06-20 16:24:56 +08:00
| Sub-page | Owns |
|---|---|
| [llm-streaming.md ](llm-streaming.md ) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler` , the `LlmAdapter` seam |
2026-07-15 14:47:29 +08:00
| [token-meter.md ](token-meter.md ) | immutable scalar and positional replay measurements with consumed-log revisions |
2026-07-12 05:13:17 +08:00
| [scope.md ](scope.md ) | scoped registration identity, dispatch carriers, and the owned `Scope` context |
2026-07-19 18:47:34 +08:00
| [goal.md ](goal.md ) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution |
2026-07-20 20:43:02 +08:00
| [commands.md ](commands.md ) | the human-command seam: definitions, adapter discovery, direct invocation, results, and parsing views |
2026-06-20 16:24:56 +08:00
| [session.md ](session.md ) | the full `SessionEventMap` variant catalog, `TurnTrigger` /`TurnEndReason` , `deriveMessages()` , the turn-enclosure invariant |
| [persistence.md ](persistence.md ) | the durability seam: `SessionPersistence` , JSONL + SQLite backends, `session/flush` , crash recovery, `SessionHeader` |
2026-07-13 13:44:01 +08:00
| [session-query.md ](session-query.md ) | logical records, bounded exact-event reads, and relationship traces |
2026-07-21 01:54:00 +08:00
| [session-title.md ](session-title.md ) | durable title snapshots, source provenance, and the asynchronous provider contract |
2026-07-13 13:09:41 +08:00
| [system-prompt.md ](system-prompt.md ) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly |
2026-07-11 22:55:40 +08:00
| [tools.md ](tools.md ) | `ToolDefinition` full fields, the schema DSL, `ToolExecution` /`ToolResult` , tool-presentation UI types, and the guarded execution pipeline |
2026-07-05 17:05:33 +08:00
| [user-interaction.md ](user-interaction.md ) | the UI-backed human question/answer seam: `AskUserQuestionRequest` , answer/options vocabulary, provider API, error taxonomy |
2026-07-11 21:37:38 +08:00
| [approval.md ](approval.md ) | the one-shot user-approval seam: `ApprovalRequest` , `ApprovalOutcome` , per-session policy, audit and answerer contracts |
2026-07-11 23:04:27 +08:00
| [bash.md ](bash.md ) | the bash executor seam: `BashExecRequest` /`Spec` , `BashRunResult` , background `BashProcess` handles |
2026-07-21 16:01:00 +08:00
| [pty.md ](pty.md ) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots |
2026-07-21 00:44:28 +08:00
| [sandbox.md ](sandbox.md ) | per-session policy resolution and the process-confinement seam: file-effect modes, execution/provider policies, `ConfinedArgv` , enforcement and fail-closed errors |
2026-07-08 02:38:47 +08:00
| [code-runtime.md ](code-runtime.md ) | the code-execution seam: `CodeRunRequest` /`Result` , binding namespaces, captured logs, the `CodeRunFailure` taxonomy |
2026-06-22 14:53:36 +08:00
| [filesystem.md ](filesystem.md ) | the filesystem seam: `FsTarget` , read/write/edit outcomes, observed-file state, `FsErrorCode` |
2026-07-16 13:11:07 +08:00
| [lsp.md ](lsp.md ) | the LSP navigation seam: `LspQueryRequest` /`Result` , `LspProvider` /`Service` , four operations, `LspError` |
2026-07-10 14:19:06 +08:00
| [skills.md ](skills.md ) | the skill service: discovery priority, `SkillSummary` /`SkillDefinition` , session-prefix catalog, model-facing `skill` loading |
2026-06-23 16:33:05 +08:00
| [compaction.md ](compaction.md ) | the compaction seam: the `compact/*` session events, `CompactionResult` , the `CompactService` interface |
Fix review findings: lifecycle containment, configurable tool name, coverage, type catalog
Address four findings from the first Codex review round:
- Contain subagent/start|end listener throws (emitContainedStart/End): a
thrown lifecycle listener could escape SubagentService.start() before the
caller received the live run to dispose it (a leaked child), and a thrown
subagent/end listener could surface as an unhandled rejection on the detached
result-settle hook. Both emits now log-and-contain, mirroring the agent
registry's agent/created|disposed containment.
- Make the model-facing tool name configurable (Config.toolName, default
subagent). The docs say to load dsh-tool-subagent once per provider to expose
multiple transports, but the hardcoded name made the second load throw a
duplicate-tool-name error; a distinct toolName per load is now required and
documented.
- Reach the per-file 100% coverage gate: tests for the subagent/end error
branch, lifecycle-listener containment, every stopReasonError arm + the
merge-extensible default, the multi-provider toolName path, agentOptions
forwarding, and the direct-apply schema-bypass fallbacks.
- Document the seam vocabulary in docs/core-data-structures/subagent.md with
verbatim type-equiv blocks + manifest entries, and link it from core.md (a
brand-new core/seam type the doc-sync gate cannot detect on its own).
2026-06-21 23:15:43 +08:00
| [subagent.md ](subagent.md ) | the subagent seam: the named-provider registry, `SubagentStartRequest` /`Result` /`Run` , the start-time-vs-runtime capability split |
2026-07-14 04:17:38 +08:00
| [web.md ](web.md ) | the web access seam: `WebSearchRequest` /`Result` , `WebFetchRequest` /`Result` , `WebFetchBody` , provider availability, `WebError` |
2026-07-13 11:07:27 +08:00
| [spill.md ](spill.md ) | the spill storage seam: `SaveTextSpill` , `SpillOwner` /`SpillSource` , `SpillRef` , the branded `SpillLocator` |
2026-07-09 18:50:29 +08:00
| [workflow.md ](workflow.md ) | the workflow seam: `WorkflowStartRequest` , `WorkflowMeta` , `WorkflowRun` /`Result` , the `workflow/*` event payloads, `WorkflowError` fatality |
2026-06-20 16:24:56 +08:00
2026-07-19 13:59:05 +08:00
> Type declarations and their JSDoc on these pages are source-equivalent and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Ordinary blocks preserve complete declarations; `public-api` blocks preserve body-stripped public class declarations. Cordis services use the generated [service catalog](../cordis-catalog/services.md).
docs: the governing principle — every LLM request is reconstructable from the session log
The reconstructability RFC is the principle's home: model-visible ⟺
logged in both forms, the mechanism (boundary derivation + header
fold), the enforcement (write-time round-trip guard, the dev
invariant), the corollaries ranked (prefix-cache stability first), the
MiniCode lineage with the provenance arrow inverted, and the
alternatives it beat — including the stateful transmission client
whose three-design archaeology lives in PR #162.
Placements per the one-home-per-fact taxonomy: a standing-order line in
root AGENTS.md (with displacement trims to stay inside the 1,575-word
ceiling), the principle statement in architecture.md § Session Log and
its Turn Flow lines (condensed to the ratcheted 1,630 ceiling), the
request-envelope section in core-data-structures/core.md with the
LlmCallConfig paste, both review-requested FIXMEs
(FIXME(call-config-shape) beside the type, FIXME(catalog-verbs) at the
catalog's drift-gate note), cookbook rows redirected off agent/request
(tool filtering → system-prompt/assemble, plan-mode prompt → sections/
inject()), and the llm/stream JSDoc stating the frozen-request
contract. RFC index and all generated catalogs regenerated.
2026-07-06 03:49:35 +08:00
2026-06-20 16:24:56 +08:00
## The `…Map → derived-union` pattern
Almost every extensible sum type in the harness follows one shape: an interface keyed by a discriminant tag (the `…Map` ), from which the union is derived with `keyof` . Plugins add variants by **declaration merging** — no edit to the owning package.
```ts ignore-check
// The pattern, schematically:
interface ThingMap {
'a': { kind: 'a'; /* … */ }
'b': { kind: 'b'; /* … */ }
}
type ThingKind = keyof ThingMap // 'a' | 'b'
type Thing = ThingMap[keyof ThingMap] // the discriminated union
// A plugin extends it without touching the source package:
declare module '@deepseek -ai/dsh-llm' {
interface ThingMap {
'c': { kind: 'c'; /* … */ }
}
}
```
Six canonical maps use this pattern; a plugin author extends these:
| Map | Package | Derives | Catalog |
|---|---|---|---|
| `ContentBlockMap` | dsh-llm | `ContentBlock` | [below ](#content-blocks-and-messages ) |
| `MessageSourceMap` | dsh-llm | `MessageSource` | [below ](#content-blocks-and-messages ) |
| `FinishReasonMap` | dsh-llm | `FinishReason` | [below ](#the-model-request-and-result ) |
| `TurnTriggerMap` | dsh-session | `TurnTrigger` | [session.md ](session.md ) |
| `TurnEndReasonMap` | dsh-session | `TurnEndReason` | [session.md ](session.md ) |
| `SessionEventMap` | dsh-session | `SessionEvent` | [session.md ](session.md ) |
Two large discriminated unions are the ones consumers `switch` over most: ** `StreamChunk` ** (the streaming protocol) and ** `SessionEvent` ** (the log entry). Per the repo convention, `switch` on the tag — don't chain `if` s — so each arm narrows and a typo'd tag fails to compile.
## Branded IDs
2026-07-14 01:59:21 +08:00
IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (a `SessionId` cannot be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings.
2026-06-20 16:24:56 +08:00
2026-07-11 23:04:27 +08:00
The `Branded<B>` primitive lives in its own type-only package, [dsh-brand ](../../packages/util/brand ) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package.
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
Source: [`packages/util/brand/src/index.ts` ](../../packages/util/brand/src/index.ts )
2026-06-20 16:24:56 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/** A string carrying a compile-time-only brand `B` . */
2026-06-20 16:24:56 +08:00
type Branded< B extends string > = string & { readonly [BRAND]: B }
```
2026-07-15 23:29:47 +08:00
The two core IDs are `CallId` (correlates a tool call with its result; dsh-llm) and `SessionId` (the shared live agent and durable session identity; dsh-session). Capability packages brand their own ids too, such as `TaskId` in [tasks.md ](tasks.md ).
2026-06-20 16:24:56 +08:00
## Content blocks and messages
A conversation is `Message` s; a message is an array of typed **content blocks** . The block union derives from `ContentBlockMap` .
2026-06-20 23:12:14 +08:00
Source: [`packages/llm/llm/src/types.ts` ](../../packages/llm/llm/src/types.ts )
2026-06-20 16:24:56 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
* Merge-extensible content blocks keyed by `type` . New core blocks must land
* with adapter, UI, and compaction support.
*/
2026-06-20 16:24:56 +08:00
interface ContentBlockMap {
'text': TextBlock
'reasoning': ReasoningBlock
'tool-call': ToolCallBlock
'tool-result': ToolResultBlock
}
```
refactor(llm): drop the image content block until a path can honor it
ImageBlock had no production producer and every consumer dropped it:
the deepseek serializer skipped it, the pi-ai converter skipped it as
unrepresentable, the ACP bridge neither advertises image prompt
capability nor forwards image blocks, and compact-basic charged a flat
85-token estimate and rendered an [image] placeholder. A block
constructed today would silently vanish from the wire — the vocabulary
advertised a capability no path honors, the silent-data-loss shape the
defensive patterns warn against. The only constructors were tests
pinning the skip/estimate branches.
Remove ImageBlock and its ContentBlockMap entry (its cache?: CacheHint
field leaves with it; CacheHint itself and the other two cache? fields
are out of scope). compact-basic loses its explicit image estimate and
placeholder arms (the merge-extensible default arms absorb the case);
the deepseek serializer, pi-ai converter, and ACP codec already handled
image in their default arms, so only their image-naming comments
change. The codec's inbound rejection of ACP-protocol image prompt
content stays — that guards wire content a client can send regardless
of our vocabulary.
Tests that constructed harness image blocks to pin the removed branches
are dropped (the 85-token estimate pin) or retargeted onto plugin-added
block types / other non-text blocks, which the surviving default arms
own. Docs, the type-equiv pastes, and the content-block vocabulary
RFC's block list and multimodal-home consequence are updated in the
same change; the RFC moves to implemented/ and the index is
regenerated. A real multimodal feature reintroduces image via
declaration merging together with the adapter mapping, ACP
advertisement, and compaction pricing that honor it.
2026-07-04 17:21:13 +08:00
The block interfaces (full fields in source): `TextBlock` (`text` ), `ReasoningBlock` (thinking, distinct from visible text), `ToolCallBlock` (`id: CallId` , `name` , raw-JSON `arguments` ), `ToolResultBlock` (`toolCallId` , nested `content: ContentBlock[]` , `isError?` ). `ContentBlock = ContentBlockMap[ContentBlockType]` . The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the merge-extensible map together with the adapter/UI/compaction support that honors it.
2026-06-20 16:24:56 +08:00
2026-07-14 21:57:52 +08:00
A `Message` is a role plus blocks. Loop-derived assistant messages carry their durable provider/model identity and optional adapter-private replay metadata:
2026-06-20 16:24:56 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/** Provider ownership and adapter-private replay data for an assistant message. */
2026-07-14 21:57:52 +08:00
interface AssistantProvenance {
/** Provider route that produced the message. */
provider: string
/** Provider model id that produced the message. */
model: string
/**
* Lossless-JSON adapter state needed to replay the provider response.
* `LlmService` exposes it to a target adapter only when that adapter instance
* currently owns both this historical provider and the target provider.
*/
replayState?: unknown
}
```
2026-06-20 16:24:56 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
* A single message in a conversation history. Loop-derived assistant messages
* always carry provenance; callers may omit it on hand-built foreign history.
*/
2026-06-20 16:24:56 +08:00
interface Message {
role: 'system' | 'user' | 'assistant'
content: ContentBlock[]
2026-07-14 21:57:52 +08:00
/** Present only on assistant messages produced by a routed adapter. */
provenance?: AssistantProvenance
2026-06-20 16:24:56 +08:00
}
```
Where a message came from is itself a merge-extensible sum type:
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
* Where a message (or injected content) came from.
* Merge-extensible sum type — plugins add their own `kind` s.
*/
2026-06-20 16:24:56 +08:00
interface MessageSourceMap {
user: { kind: 'user' }
plugin: { kind: 'plugin'; plugin: string }
}
```
## Streaming
Adapters emit a raw **chunk** protocol; the loop logs the chunks (replay fidelity) while feeding the same chunks through a `BlockAssembler` to rebuild blocks and messages. `StreamChunk` is a closed discriminated union over `type` — `block-start` , `text-delta` , `reasoning-delta` , `tool-call-delta` , `block-end` , `usage` , `finish` .
The full union, the adapter contract (usage-before-finish, raw-JSON tool arguments, the two sanctioned error paths), and `BlockAssembler` live on ** [llm-streaming.md ](llm-streaming.md )**.
2026-07-23 01:05:50 +08:00
< a id = "the-model-request-and-result" > < / a >
2026-06-21 01:27:41 +08:00
## The model request
2026-06-20 16:24:56 +08:00
2026-06-21 01:27:41 +08:00
One model call is a fully-assembled `GenerateOptions` . The adapter answers with a raw `StreamChunk` stream; the consumer assembles it with `BlockAssembler` (see [llm-streaming.md ](llm-streaming.md )).
2026-06-20 16:24:56 +08:00
2026-06-20 23:12:14 +08:00
Source: [`packages/llm/llm/src/types.ts` ](../../packages/llm/llm/src/types.ts )
2026-06-20 16:24:56 +08:00
2026-07-15 13:33:42 +08:00
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
2026-07-19 12:25:40 +08:00
/** Display metadata for one registered provider route. */
2026-07-15 13:33:42 +08:00
interface LlmProviderInfo {
/** Provider route key used by {@link GenerateOptions.provider}. */
id: string
/** Human-readable provider name for selectors and diagnostics. */
name: string
}
```
```ts type-equiv
2026-07-19 12:25:40 +08:00
/** One adapter-discovered model; catalog membership is advisory, not request validation. */
2026-07-15 13:33:42 +08:00
interface LlmModelInfo {
/** Provider route that owns this model entry. */
provider: string
/** Model id passed to {@link GenerateOptions.model}. */
id: string
/** Human-readable model name for selectors. */
name: string
/** Optional user-facing distinction from otherwise similar models. */
description?: string
}
```
2026-07-20 15:34:00 +08:00
Correctness-sensitive model capacity is queried separately from the advisory catalog and is owned by the adapter serving the exact route.
2026-06-20 16:24:56 +08:00
```ts type-equiv
2026-07-20 15:34:00 +08:00
/** Provider-owned context capacity for one exact provider/model route. */
interface LlmModelContext {
/** Maximum combined request and response context in tokens. */
contextWindow: number
}
```
2026-07-15 13:33:42 +08:00
2026-06-20 16:24:56 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/** A single model request, fully assembled. */
2026-06-20 16:24:56 +08:00
interface GenerateOptions {
2026-07-14 21:57:52 +08:00
/** Registered provider route selecting the adapter instance. */
provider: string
2026-06-20 16:24:56 +08:00
model: string
feat(agent): add the agent/request-messages request-only message seam
A new waterfall near request construction lets plugins contribute
request-ONLY messages framing the derived history: RequestMessages
{ before, after } with a frozen empty seed, fired inside the open step
after the agent/request config waterfall, so the step/start boundary
snapshot and its same-sync-frame invariant are untouched. The request
becomes messagePrefix + boundary snapshot + messageSuffix.
Contributions never enter session history — deriveMessages() is
unchanged — so the request header is their durable record:
EpochHeader gains messagePrefix/messageSuffix (canonical absence for
empty arrays), request/header-delta replaces either array whole with
an empty array encoding the transition back to absence, and the
dev-mode reconstruction cross-check now expects the folded header's
framing around the boundary derivation.
This is the seam for per-request advisory context that must be
model-visible now without becoming durable history (a skills catalog,
an environment reminder), keeping the base system prompt
workspace-independent and provider prefix caches stable. The docs
carry the channel cost model: session-frozen content belongs in
before, low-frequency change notices belong in durable history via
inject() (paid once, prefix-cached thereafter), and after is reserved
for small frequently-refreshed state snapshots re-paid on every
request they ride. No shipped producer yet, so ACP snapshot fixtures
are byte-identical.
2026-07-07 19:42:30 +08:00
/**
* Ordered conversation messages, exactly as the provider sees them (after
* the `system` slot). A loop-built request assembles them as
refactor(agent): replace the per-step advice seam with agent/session-prefix
Review discussion converged on the industry shape (Claude Code caches
user context per conversation; Codex separates initial context from
diffs; Kimi appends at continuation boundaries to protect prompt
caching): stable openers belong in a compose-once prefix, mid-session
changes belong in append-only history — not in a per-request slot.
agent/session-prefix fires ONCE per loop instance, lazily on its first
request-building step: the composed Message[] is deep-frozen, cached on
the transmission bookkeeping, recorded as EpochHeader.messagePrefix on
the anchoring 'initial'/'resume' snapshot, and reused verbatim for
every request the instance sends — prefix stability is structural, not
a producer discipline, and a resume recomposes with attributable drift.
The request is messagePrefix + boundary snapshot.
The per-step RequestAdvice/RequestAdviceContext surface and the
messageSuffix header field are dropped: the tail slot had no consumer,
and every current update pattern (new AGENTS.md discovered, memory
update, skills change) routes through the existing append-only history
channels — inject(), tools/post-execute additionalContext,
prompt-submit additionalContext — each paid once and prefix-cached
thereafter. The messagePrefix delta arm stays for codec totality; the
loop never produces one in practice.
2026-07-08 15:44:30 +08:00
* `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a
* hand-built one-shot passes any list.
feat(agent): add the agent/request-messages request-only message seam
A new waterfall near request construction lets plugins contribute
request-ONLY messages framing the derived history: RequestMessages
{ before, after } with a frozen empty seed, fired inside the open step
after the agent/request config waterfall, so the step/start boundary
snapshot and its same-sync-frame invariant are untouched. The request
becomes messagePrefix + boundary snapshot + messageSuffix.
Contributions never enter session history — deriveMessages() is
unchanged — so the request header is their durable record:
EpochHeader gains messagePrefix/messageSuffix (canonical absence for
empty arrays), request/header-delta replaces either array whole with
an empty array encoding the transition back to absence, and the
dev-mode reconstruction cross-check now expects the folded header's
framing around the boundary derivation.
This is the seam for per-request advisory context that must be
model-visible now without becoming durable history (a skills catalog,
an environment reminder), keeping the base system prompt
workspace-independent and provider prefix caches stable. The docs
carry the channel cost model: session-frozen content belongs in
before, low-frequency change notices belong in durable history via
inject() (paid once, prefix-cached thereafter), and after is reserved
for small frequently-refreshed state snapshots re-paid on every
request they ride. No shipped producer yet, so ACP snapshot fixtures
are byte-identical.
2026-07-07 19:42:30 +08:00
*/
2026-06-20 16:24:56 +08:00
messages: Message[]
/** System prompt text (adapters map to the provider's system slot). */
system?: string
/** Tool schemas (adapters map to the provider's `tools` field). */
tools?: ToolSchema[]
temperature?: number
maxTokens?: number
/**
* Stop sequences: generation halts as soon as the model produces any one of
* these strings (adapters map to the provider's stop field, e.g. OpenAI
* `stop` ). The stop string itself is not included in the output.
*/
stop?: string[]
signal?: AbortSignal
Add per-session snapshot replay for nested agents (PR2.5)
The snapshot tier was built single-session: dsh-llm-replay served calls from
one global positional cursor, and the harness harvested one session log. A
subagent runs as a second agent with its own session, so a parent→child
scenario could neither replay deterministically nor harvest the child's log.
This resolves the TODO(subagent-snapshots) deferral from the subagent RFC.
- Stamp the calling session id onto the model request: GenerateOptions.sessionId
(typed Branded<'SessionId'> to avoid the dsh-llm↔dsh-session cycle), set by the
agent loop from agent.session.id. Adapters ignore it; an llm/stream listener
routes by it.
- Key replay per session: dsh-llm-replay loads the parent log plus one per child
(childFiles / $DSH_SNAPSHOT_CHILD_FILES), derives a script per recorded session,
and binds each live (freshly-random) session to a recorded script by first-call
order — parent first (earliest createdAt, first to stream). Keys by WHO calls,
so it survives a future concurrent/backgrounded subagent; a global cursor would
not. An unrecorded extra session fails loud.
- Harvest every log: the harness collects all .jsonl across cwd buckets, ordered
primary-first (top-level, then children by createdAt), and RunResult exposes the
plural sessionLogs. The spec writes each back on record (session.jsonl +
session.<n>.jsonl) and diffs each against its fixture on replay.
- Wire the subagent seam + spawn + fork + tool into the acp-agent example (both
cordis configs) and add two nested scenarios recorded against the real API:
subagent-spawn (parent + 1 child) and subagent-multi (parent + 2 children, 3
sessions). Both replay keyless in the default gate.
A new RFC documents the design (docs/rfc/implemented/testing/). Single-session
replay is unchanged (a call with no sessionId is one anonymous primary session).
TODO follow-up: a dedicated branded-ids package could own the SessionId brand and
dissolve the cross-package cycle note; out of scope for this testing PR.
2026-06-22 08:39:36 +08:00
/**
2026-07-14 12:34:14 +08:00
* Session identity stamped by the loop for listener routing. Adapters ignore
* it; replay uses it to keep concurrent parent and child cursors independent.
Add per-session snapshot replay for nested agents (PR2.5)
The snapshot tier was built single-session: dsh-llm-replay served calls from
one global positional cursor, and the harness harvested one session log. A
subagent runs as a second agent with its own session, so a parent→child
scenario could neither replay deterministically nor harvest the child's log.
This resolves the TODO(subagent-snapshots) deferral from the subagent RFC.
- Stamp the calling session id onto the model request: GenerateOptions.sessionId
(typed Branded<'SessionId'> to avoid the dsh-llm↔dsh-session cycle), set by the
agent loop from agent.session.id. Adapters ignore it; an llm/stream listener
routes by it.
- Key replay per session: dsh-llm-replay loads the parent log plus one per child
(childFiles / $DSH_SNAPSHOT_CHILD_FILES), derives a script per recorded session,
and binds each live (freshly-random) session to a recorded script by first-call
order — parent first (earliest createdAt, first to stream). Keys by WHO calls,
so it survives a future concurrent/backgrounded subagent; a global cursor would
not. An unrecorded extra session fails loud.
- Harvest every log: the harness collects all .jsonl across cwd buckets, ordered
primary-first (top-level, then children by createdAt), and RunResult exposes the
plural sessionLogs. The spec writes each back on record (session.jsonl +
session.<n>.jsonl) and diffs each against its fixture on replay.
- Wire the subagent seam + spawn + fork + tool into the acp-agent example (both
cordis configs) and add two nested scenarios recorded against the real API:
subagent-spawn (parent + 1 child) and subagent-multi (parent + 2 children, 3
sessions). Both replay keyless in the default gate.
A new RFC documents the design (docs/rfc/implemented/testing/). Single-session
replay is unchanged (a call with no sessionId is one anonymous primary session).
TODO follow-up: a dedicated branded-ids package could own the SessionId brand and
dissolve the cross-package cycle note; out of scope for this testing PR.
2026-06-22 08:39:36 +08:00
*/
sessionId?: Branded< 'SessionId'>
2026-07-22 18:55:21 +08:00
/**
2026-07-22 18:59:49 +08:00
* Provider-neutral classification for an auxiliary model call. Adapters may
* map the purpose to model-hidden transport metadata. Ordinary conversation
* requests leave it unset.
2026-07-22 18:55:21 +08:00
*/
2026-07-22 18:59:49 +08:00
purpose?: 'compaction'
2026-06-20 16:24:56 +08:00
}
```
2026-07-20 18:38:22 +08:00
Why a model response stopped is a merge-extensible reason. Terminal provider failures carry the streaming contract's [`LlmFailure` ](llm-streaming.md#llmfailure ):
2026-06-20 16:24:56 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
* Why a model response stopped.
* Merge-extensible so adapters can surface provider-specific reasons.
*/
2026-06-20 16:24:56 +08:00
interface FinishReasonMap {
'stop': { kind: 'stop' }
'tool-calls': { kind: 'tool-calls' }
'max-tokens': { kind: 'max-tokens' }
2026-07-20 03:34:19 +08:00
'aborted': { kind: 'aborted'; failure: LlmFailure }
'error': { kind: 'error'; failure: LlmFailure }
2026-06-20 16:24:56 +08:00
}
```
`FinishReason = FinishReasonMap[keyof FinishReasonMap]` . `TokenUsage` (per-call accounting with disjoint cache fields) is detailed on [llm-streaming.md ](llm-streaming.md ).
`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
2026-07-19 12:25:40 +08:00
/**
* 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.
*/
2026-06-20 16:24:56 +08:00
interface ToolSchema {
name: string
description: string
/** JSON Schema object for the arguments. */
parameters: Record< string , unknown >
}
```
The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` that produces it (schema + `execute` ) is on [tools.md ](tools.md ).
docs: the governing principle — every LLM request is reconstructable from the session log
The reconstructability RFC is the principle's home: model-visible ⟺
logged in both forms, the mechanism (boundary derivation + header
fold), the enforcement (write-time round-trip guard, the dev
invariant), the corollaries ranked (prefix-cache stability first), the
MiniCode lineage with the provenance arrow inverted, and the
alternatives it beat — including the stateful transmission client
whose three-design archaeology lives in PR #162.
Placements per the one-home-per-fact taxonomy: a standing-order line in
root AGENTS.md (with displacement trims to stay inside the 1,575-word
ceiling), the principle statement in architecture.md § Session Log and
its Turn Flow lines (condensed to the ratcheted 1,630 ceiling), the
request-envelope section in core-data-structures/core.md with the
LlmCallConfig paste, both review-requested FIXMEs
(FIXME(call-config-shape) beside the type, FIXME(catalog-verbs) at the
catalog's drift-gate note), cookbook rows redirected off agent/request
(tool filtering → system-prompt/assemble, plan-mode prompt → sections/
inject()), and the llm/stream JSDoc stating the frozen-request
contract. RFC index and all generated catalogs regenerated.
2026-07-06 03:49:35 +08:00
### The request envelope: `LlmCallConfig` and the logged header
2026-07-19 22:50:49 +08:00
The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative returned tool order (configured by `toolOrder` , or lexicographic when unset), and session prefix through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md ](session.md#the-request-header-event-requestheader ) and the [reconstructability Agent Note ](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md ).
2026-07-13 16:24:32 +08:00
2026-07-21 14:09:17 +08:00
`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, or sampling. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests.
feat(agent): add the agent/request-messages request-only message seam
A new waterfall near request construction lets plugins contribute
request-ONLY messages framing the derived history: RequestMessages
{ before, after } with a frozen empty seed, fired inside the open step
after the agent/request config waterfall, so the step/start boundary
snapshot and its same-sync-frame invariant are untouched. The request
becomes messagePrefix + boundary snapshot + messageSuffix.
Contributions never enter session history — deriveMessages() is
unchanged — so the request header is their durable record:
EpochHeader gains messagePrefix/messageSuffix (canonical absence for
empty arrays), request/header-delta replaces either array whole with
an empty array encoding the transition back to absence, and the
dev-mode reconstruction cross-check now expects the folded header's
framing around the boundary derivation.
This is the seam for per-request advisory context that must be
model-visible now without becoming durable history (a skills catalog,
an environment reminder), keeping the base system prompt
workspace-independent and provider prefix caches stable. The docs
carry the channel cost model: session-frozen content belongs in
before, low-frequency change notices belong in durable history via
inject() (paid once, prefix-cached thereafter), and after is reserved
for small frequently-refreshed state snapshots re-paid on every
request they ride. No shipped producer yet, so ACP snapshot fixtures
are byte-identical.
2026-07-07 19:42:30 +08:00
refactor(agent): replace the per-step advice seam with agent/session-prefix
Review discussion converged on the industry shape (Claude Code caches
user context per conversation; Codex separates initial context from
diffs; Kimi appends at continuation boundaries to protect prompt
caching): stable openers belong in a compose-once prefix, mid-session
changes belong in append-only history — not in a per-request slot.
agent/session-prefix fires ONCE per loop instance, lazily on its first
request-building step: the composed Message[] is deep-frozen, cached on
the transmission bookkeeping, recorded as EpochHeader.messagePrefix on
the anchoring 'initial'/'resume' snapshot, and reused verbatim for
every request the instance sends — prefix stability is structural, not
a producer discipline, and a resume recomposes with attributable drift.
The request is messagePrefix + boundary snapshot.
The per-step RequestAdvice/RequestAdviceContext surface and the
messageSuffix header field are dropped: the tail slot had no consumer,
and every current update pattern (new AGENTS.md discovered, memory
update, skills change) routes through the existing append-only history
channels — inject(), tools/post-execute additionalContext,
prompt-submit additionalContext — each paid once and prefix-cached
thereafter. The messagePrefix delta arm stays for codec totality; the
loop never produces one in practice.
2026-07-08 15:44:30 +08:00
On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request.
docs: the governing principle — every LLM request is reconstructable from the session log
The reconstructability RFC is the principle's home: model-visible ⟺
logged in both forms, the mechanism (boundary derivation + header
fold), the enforcement (write-time round-trip guard, the dev
invariant), the corollaries ranked (prefix-cache stability first), the
MiniCode lineage with the provenance arrow inverted, and the
alternatives it beat — including the stateful transmission client
whose three-design archaeology lives in PR #162.
Placements per the one-home-per-fact taxonomy: a standing-order line in
root AGENTS.md (with displacement trims to stay inside the 1,575-word
ceiling), the principle statement in architecture.md § Session Log and
its Turn Flow lines (condensed to the ratcheted 1,630 ceiling), the
request-envelope section in core-data-structures/core.md with the
LlmCallConfig paste, both review-requested FIXMEs
(FIXME(call-config-shape) beside the type, FIXME(catalog-verbs) at the
catalog's drift-gate note), cookbook rows redirected off agent/request
(tool filtering → system-prompt/assemble, plan-mode prompt → sections/
inject()), and the llm/stream JSDoc stating the frozen-request
contract. RFC index and all generated catalogs regenerated.
2026-07-06 03:49:35 +08:00
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
2026-07-19 12:25:40 +08:00
/**
* 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.
*/
docs: the governing principle — every LLM request is reconstructable from the session log
The reconstructability RFC is the principle's home: model-visible ⟺
logged in both forms, the mechanism (boundary derivation + header
fold), the enforcement (write-time round-trip guard, the dev
invariant), the corollaries ranked (prefix-cache stability first), the
MiniCode lineage with the provenance arrow inverted, and the
alternatives it beat — including the stateful transmission client
whose three-design archaeology lives in PR #162.
Placements per the one-home-per-fact taxonomy: a standing-order line in
root AGENTS.md (with displacement trims to stay inside the 1,575-word
ceiling), the principle statement in architecture.md § Session Log and
its Turn Flow lines (condensed to the ratcheted 1,630 ceiling), the
request-envelope section in core-data-structures/core.md with the
LlmCallConfig paste, both review-requested FIXMEs
(FIXME(call-config-shape) beside the type, FIXME(catalog-verbs) at the
catalog's drift-gate note), cookbook rows redirected off agent/request
(tool filtering → system-prompt/assemble, plan-mode prompt → sections/
inject()), and the llm/stream JSDoc stating the frozen-request
contract. RFC index and all generated catalogs regenerated.
2026-07-06 03:49:35 +08:00
interface LlmCallConfig {
2026-07-14 21:57:52 +08:00
provider: string
docs: the governing principle — every LLM request is reconstructable from the session log
The reconstructability RFC is the principle's home: model-visible ⟺
logged in both forms, the mechanism (boundary derivation + header
fold), the enforcement (write-time round-trip guard, the dev
invariant), the corollaries ranked (prefix-cache stability first), the
MiniCode lineage with the provenance arrow inverted, and the
alternatives it beat — including the stateful transmission client
whose three-design archaeology lives in PR #162.
Placements per the one-home-per-fact taxonomy: a standing-order line in
root AGENTS.md (with displacement trims to stay inside the 1,575-word
ceiling), the principle statement in architecture.md § Session Log and
its Turn Flow lines (condensed to the ratcheted 1,630 ceiling), the
request-envelope section in core-data-structures/core.md with the
LlmCallConfig paste, both review-requested FIXMEs
(FIXME(call-config-shape) beside the type, FIXME(catalog-verbs) at the
catalog's drift-gate note), cookbook rows redirected off agent/request
(tool filtering → system-prompt/assemble, plan-mode prompt → sections/
inject()), and the llm/stream JSDoc stating the frozen-request
contract. RFC index and all generated catalogs regenerated.
2026-07-06 03:49:35 +08:00
model: string
temperature?: number
maxTokens?: number
stop?: string[]
}
```
2026-06-20 16:24:56 +08:00
## Sessions
A `Session` is an **append-only log** of typed `SessionEvent` s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()` ), not stored separately. The event vocabulary derives from `SessionEventMap` :
2026-06-20 23:12:14 +08:00
Source: [`packages/core/session/src/types.ts` ](../../packages/core/session/src/types.ts )
2026-06-20 16:24:56 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
* 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.
*/
2026-06-20 16:24:56 +08:00
type SessionEvent< T extends SessionEventType = SessionEventType > = {
[K in SessionEventType]: {
type: K
/** Monotonic sequence number within the session. */
seq: number
/** Unix epoch milliseconds. */
time: number
data: SessionEventMap[K]
2026-06-22 10:35:59 +08:00
} & (K extends SurfaceEventType ? {
/**
* Seq numbers of events that are provenance sources of this event
* (e.g. the `assistant/chunk` seqs that built an `assistant/message` ,
2026-07-19 12:25:40 +08:00
* 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.
2026-06-22 10:35:59 +08:00
*/
sourceEventSeqs?: number[]
/** How this event entered the surface; absent for non-surface events. */
surfaceOp?: SurfaceOp
} : object)
2026-06-20 16:24:56 +08:00
}[T]
```
2026-07-13 23:56:10 +08:00
The fourteen event variants (`turn/start` , `turn/end` , `step/start` , `step/end` , `user/message` , `prompt/blocked` , `context/message` , `assistant/chunk` , `assistant/message` , `tool/call` , `tool/result` , `steering/message` , `todo/write` , `request/header` ), the `deriveMessages()` projection rules, the `TurnTrigger` /`TurnEndReason` reasons, and the turn-enclosure invariant are on ** [session.md ](session.md )**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on ** [persistence.md ](persistence.md )**.
2026-06-20 16:24:56 +08:00
## The agent handle
2026-07-14 02:32:35 +08:00
`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is package-internal to dsh-agent-loop; nothing outside the loop depends on it.
2026-06-20 16:24:56 +08:00
2026-06-20 23:12:14 +08:00
Source: [`packages/core/agent/src/types.ts` ](../../packages/core/agent/src/types.ts )
2026-06-20 16:24:56 +08:00
2026-07-21 16:46:48 +08:00
```ts type-equiv
/**
* Message options. An omitted source attests direct human input as `{ kind: 'user' }`
* and may authorize policy consumers, so non-human producers must label their content.
*/
interface SendOptions {
source?: MessageSource
/**
* Model-facing contexts captured with this inbox item. A queued prompt exposes
* them through the default `agent/prompt-submit` allow decision, while steering
* records them directly at its next checkpoint.
*/
contexts?: HookContext[]
}
```
2026-07-21 17:53:30 +08:00
`InjectOptions` accepts ordinary message attribution and durable model-hidden JSON metadata. Attached contexts belong only to queued or steering input, so synthetic injection cannot accept them:
2026-07-10 14:32:44 +08:00
2026-06-20 16:24:56 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/** Options specific to durable synthetic context injection. */
2026-07-21 17:53:30 +08:00
interface InjectOptions extends Omit< SendOptions , ' contexts ' > {
2026-07-19 12:25:40 +08:00
/** Opaque JSON state retained in the session event but hidden from the model. */
2026-07-10 14:32:44 +08:00
meta?: JsonValue
}
```
2026-07-16 18:12:34 +08:00
```ts type-equiv
2026-07-20 21:38:49 +08:00
/** Stable runtime cause accepted by {@link Agent.cancel}. */
2026-07-16 18:12:34 +08:00
type AgentCancelCause =
| { readonly kind: 'user' }
| { readonly kind: 'parent' }
```
2026-06-20 16:24:56 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop` . */
2026-06-20 16:24:56 +08:00
interface Agent {
2026-07-19 12:25:40 +08:00
/** The single identity shared with {@link session}. */
2026-07-14 01:59:21 +08:00
readonly id: SessionId
2026-06-20 16:24:56 +08:00
readonly options: AgentOptions
readonly session: Session
readonly status: AgentStatus
2026-07-19 12:25:40 +08:00
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
2026-07-09 01:36:14 +08:00
readonly ctx: Context
2026-07-12 16:54:37 +08:00
/**
2026-07-20 11:53:49 +08:00
* Queue one detached, frozen lossless-JSON item. If claimed, it is the sole
* ordinary message in its FIFO-ordered turn; the next claimed item waits for
* that turn's checkpoint.
2026-07-21 16:46:48 +08:00
* Attached contexts share the same snapshot and ownership boundary. Invalid
* input throws synchronously before notification or enqueue.
2026-07-12 16:54:37 +08:00
*/
2026-06-20 16:24:56 +08:00
send(content: ContentBlock[], options?: SendOptions): void
/**
2026-07-20 14:42:55 +08:00
* Submit steering while the agent is `running` . An open turn records it at
* the next steering checkpoint before a request or continuation decision;
* policy may stop before another step. After turn close and its checkpoint,
* any remainder is queued for a later turn; terminal `agent/turn-stop` ,
* cancellation, or disposal may discard it. Uses the same synchronous
* snapshot-and-validation boundary as {@link send}; when idle, delegates to it.
2026-06-20 16:24:56 +08:00
*/
steer(content: ContentBlock[], options?: SendOptions): void
/**
2026-07-19 12:25:40 +08:00
* Append detached model-facing context without running the model. An open-turn
* injection joins at the current log position unless the current tool batch is
* executing; then it waits FIFO until that batch settles and drains before turn
* close even when interrupted. Idle injection uses a one-shot turn and durability
* checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error` .
2026-06-20 16:24:56 +08:00
*/
2026-07-10 14:32:44 +08:00
inject(content: ContentBlock[], options?: InjectOptions): void
2026-06-20 16:24:56 +08:00
/**
2026-07-20 11:52:30 +08:00
* Clear all queued and steering work, including items waiting to start, and
2026-07-21 12:48:46 +08:00
* abort the active turn. An effective call first emits
* `agent/cancel-requested` with the resolved typed cause. The first cause wins
* for the active turn, and `whenIdle()` resolves after cancellation reaches
* quiescence. Omission means `{ kind: 'user' }` . Idle cancellation is a no-op
* and does not arm later work. The active turn snapshots and freezes the cause.
2026-07-16 18:12:34 +08:00
* @param cause - the stable caller intent carried by the current turn signal.
2026-06-20 16:24:56 +08:00
*/
2026-07-16 18:12:34 +08:00
cancel(cause?: AgentCancelCause): void
2026-06-20 16:24:56 +08:00
2026-07-19 12:25:40 +08:00
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
2026-06-20 16:24:56 +08:00
whenIdle(): Promise< void >
}
```
2026-07-22 17:45:58 +08:00
`AgentStatus` is `'idle' | 'running' | 'disposed'` , and `SessionId` is branded. `running` describes the driver-wide drain interval, which can span turn close, its durability checkpoint, and consecutive queued turns; it does not prove a turn is still open. `AgentOptions` is merge-extensible: core declares `provider?` and `model?` (dispatch requires both after `agent/request` ). Persona belongs to `dsh-system-prompt` : an agent-scoped `deployment:persona` may shadow the global default.
2026-07-13 16:24:32 +08:00
2026-07-21 12:14:53 +08:00
The cause is a TypeScript-enforced same-process input. An active holder copies its discriminant into the runtime-only `AbortSignal.reason` ; it is retired before `turn/end` publication. `agentInterruptReasonOf(signal)` recognizes `user` , `parent` , and lifecycle-only `disposed` without consulting ambient initiator state. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result.
2026-07-13 16:24:32 +08:00
2026-07-14 16:21:41 +08:00
The [event taxonomy ](../architecture.md#event ) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits.
2026-06-30 17:11:18 +08:00
2026-07-19 13:30:45 +08:00
## Initiating Agent
2026-07-16 16:29:46 +08:00
2026-07-19 22:50:49 +08:00
The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision ](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md ) owns its lifetime and boundary rules.
2026-07-16 16:29:46 +08:00
2026-06-30 17:11:18 +08:00
## Interception decisions
2026-07-22 17:34:31 +08:00
Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision` /`PostToolDecision` in [tools.md ](tools.md ) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision` /`decision` /`continue` /`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext` , which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes `context/message` ; `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape.
2026-06-30 17:11:18 +08:00
Source: [`packages/core/agent/src/types.ts` ](../../packages/core/agent/src/types.ts )
```ts type-equiv
2026-07-21 16:46:48 +08:00
/** Model-facing context injected by a listener or atomically attached to one inbox message. */
2026-06-30 17:11:18 +08:00
interface HookContext {
content: ContentBlock[]
source: MessageSource
2026-07-22 17:34:31 +08:00
/**
* Model placement. Absent or `separate` records an independent
* `context/message` ; `prompt-prefix` prepends this context and a stable
* request delimiter to the same user-role message as its attached prompt.
*/
placement?: 'separate' | 'prompt-prefix'
2026-07-19 12:25:40 +08:00
/** Opaque JSON state retained in the session event but hidden from the model. */
2026-07-10 14:32:44 +08:00
meta?: JsonValue
2026-06-30 17:11:18 +08:00
}
```
2026-07-17 17:25:06 +08:00
`agent/prompt-submit` returns a `PromptDecision` (allow the turn's claimed queued message — optionally rewriting its `content` or attaching `additionalContexts` — or record `prompt/blocked` and end that zero-step turn as `rejected` ):
2026-06-30 17:11:18 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
2026-07-22 17:34:31 +08:00
* Prompt interception result. `allow.content` replaces the prompt. Each
* `additionalContexts` entry follows its declared placement: separate context
* message by default, or a prefix inside the prompt's user-role message.
* `block` records a durable `prompt/blocked` and ends the claimed prompt's
* zero-step turn as rejected. An `allow` returned by a listener is
* authoritative: a listener wrapping `next()` preserves downstream `content`
* and `additionalContexts` unless it intentionally replaces them.
2026-07-19 12:25:40 +08:00
*/
2026-06-30 17:11:18 +08:00
type PromptDecision =
2026-07-13 16:31:03 +08:00
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
2026-06-30 17:11:18 +08:00
| { kind: 'block'; reason: string }
```
2026-07-20 14:37:04 +08:00
`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 metadata — the typed `/goal` pattern):
2026-06-30 17:11:18 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */
2026-06-30 17:11:18 +08:00
type ContinuationDecision =
| { action: 'stop' }
2026-07-13 16:31:03 +08:00
| { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } }
2026-06-30 17:11:18 +08:00
```
2026-07-20 03:34:19 +08:00
`agent/request-error` receives the exact original `RequestError` beside its immutable `LlmFailure` , an immutable list of failures that already authorized another request in the consecutive sequence, the turn signal, and `next()` . Recovery plugins route on `failure.code` , not the live error's message; each policy counts only its own codes, and a successful request clears the history:
2026-07-15 16:03:52 +08:00
```ts type-equiv
2026-07-19 12:40:56 +08:00
/** Model-request failure with an optional machine-routable provider code. */
2026-07-15 16:03:52 +08:00
type RequestError = Error & { code?: string }
```
2026-07-20 03:34:19 +08:00
It returns a `RequestErrorDecision` ; `retry` opens a new numbered step after the recovery listener's durable mutation, while `fail` retains the structured failure on `turn/end` :
2026-07-15 16:03:52 +08:00
```ts type-equiv
2026-07-19 12:40:56 +08:00
/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()` . */
2026-07-15 16:03:52 +08:00
type RequestErrorDecision = { action: 'fail' } | { action: 'retry' }
2026-06-30 17:11:18 +08:00
```
2026-07-19 12:35:43 +08:00
`agent/post-step` is awaited after assistant output, real or synthetic tool results, buffered context, and steering are durable but before `step/end` . A cancelled tool batch reaches it with an aborted signal after draining; its signature is `(agent, turn, step, signal)` , and replayable facts remain in the session log rather than a transient payload.
2026-07-15 16:03:52 +08:00
2026-07-11 22:55:40 +08:00
`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
2026-07-19 12:25:40 +08:00
/**
* The terminal subset of {@link ContinuationDecision}. A listener on
* `agent/turn-stop` returns this to make the already-composed continuation
* outcome terminal; `undefined` abstains.
*/
2026-07-11 22:55:40 +08:00
type ContinuationStop = Extract< ContinuationDecision , { action: ' stop ' } >
```
2026-06-30 17:11:18 +08:00
`agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it):
```ts type-equiv
2026-07-19 12:25:40 +08:00
/** Why a session lifecycle began; seeded creates are `startup` , while persisted loads are `resume` . */
2026-06-30 17:11:18 +08:00
type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
```
2026-06-20 16:24:56 +08:00
2026-07-13 16:24:32 +08:00
`agent/session-prefix` composes a `Message[]` once per loop instance. The deep-frozen result is recorded in the request header and prepended to every derived history, making it the home for session-stable openers. A resumed instance recomposes; mid-session changes use append-only context channels. The waterfall returns content directly because it contributes rather than decides.
feat(agent): add the agent/request-messages request-only message seam
A new waterfall near request construction lets plugins contribute
request-ONLY messages framing the derived history: RequestMessages
{ before, after } with a frozen empty seed, fired inside the open step
after the agent/request config waterfall, so the step/start boundary
snapshot and its same-sync-frame invariant are untouched. The request
becomes messagePrefix + boundary snapshot + messageSuffix.
Contributions never enter session history — deriveMessages() is
unchanged — so the request header is their durable record:
EpochHeader gains messagePrefix/messageSuffix (canonical absence for
empty arrays), request/header-delta replaces either array whole with
an empty array encoding the transition back to absence, and the
dev-mode reconstruction cross-check now expects the folded header's
framing around the boundary derivation.
This is the seam for per-request advisory context that must be
model-visible now without becoming durable history (a skills catalog,
an environment reminder), keeping the base system prompt
workspace-independent and provider prefix caches stable. The docs
carry the channel cost model: session-frozen content belongs in
before, low-frequency change notices belong in durable history via
inject() (paid once, prefix-cached thereafter), and after is reserved
for small frequently-refreshed state snapshots re-paid on every
request they ride. No shipped producer yet, so ACP snapshot fixtures
are byte-identical.
2026-07-07 19:42:30 +08:00
2026-06-20 16:24:56 +08:00
## `ToolDefinition`
The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional UI presenters. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through.
Its full fields, the `defineTool` /`SchemaSpec` /`InferArgs` typed schema DSL, the `ToolExecution` /`ToolExecutionResult` waterfall shapes, and the tool-presentation UI vocabulary are on ** [tools.md ](tools.md )**.