2026-06-20 16:24:56 +08:00
# Core Data Structures
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-12 05:13:17 +08:00
| [scope.md ](scope.md ) | scoped registration identity, dispatch carriers, and the owned `Scope` context |
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-11 12:20:35 +08:00
| [session-query.md ](session-query.md ) | logical session/event records and bounded exact-event reads |
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-11 21:37:38 +08:00
| [sandbox.md ](sandbox.md ) | the process-confinement seam: file-effect modes, `SandboxPolicy` , `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-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-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
> 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.
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(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.
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
IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (an `AgentId` can't be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings.
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
type Branded< B extends string > = string & { readonly [BRAND]: B }
```
2026-07-11 23:04:27 +08:00
The three core IDs are `CallId` , `SessionId` , and `AgentId` . 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
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
A `Message` is a role plus blocks:
```ts type-equiv
interface Message {
role: 'system' | 'user' | 'assistant'
content: ContentBlock[]
}
```
Where a message came from is itself a merge-extensible sum type:
```ts type-equiv
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-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
```ts type-equiv
interface GenerateOptions {
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-06-20 16:24:56 +08:00
}
```
Why a model response stopped is a merge-extensible reason:
```ts type-equiv
interface FinishReasonMap {
'stop': { kind: 'stop' }
'tool-calls': { kind: 'tool-calls' }
'max-tokens': { kind: 'max-tokens' }
'aborted': { kind: 'aborted' }
'error': { kind: 'error'; message: string; code?: string }
}
```
`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
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-13 23:27:00 +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 `request/header` snapshots and deltas. Together with derived history, this makes the request reconstructable from the session log. See [session.md ](session.md#the-request-header-events-requestheader-and-requestheader-delta ) and the [reconstructability RFC ](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md ).
2026-07-13 16:24:32 +08:00
2026-07-13 23:27:00 +08:00
`agent/request` receives a frozen call-config seed and may return a replacement. `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.
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
interface LlmCallConfig {
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
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-06-26 16:42:51 +08:00
* or the surface nodes shadowed by a compaction replace node).
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-06 02:42:51 +08:00
The fifteen 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` , `request/header-delta` ), 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
`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is `ReactLoopAgent` in dsh-agent-loop; nothing outside the loop depends on the implementation.
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
```ts type-equiv
interface Agent {
readonly id: AgentId
readonly options: AgentOptions
readonly session: Session
readonly status: AgentStatus
2026-07-09 01:36:14 +08:00
/**
* 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
2026-07-12 16:54:37 +08:00
/**
* Queue a user message. Starts a turn when idle; otherwise waits for the next
* turn. Content and the resolved source are accepted as one detached,
* 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.
*/
2026-06-20 16:24:56 +08:00
send(content: ContentBlock[], options?: SendOptions): void
/**
* Steer a running turn: content is injected between steps of the current
2026-07-12 16:54:37 +08:00
* turn. Uses the same owned-value and synchronous-validation boundary as
* {@link send}; when idle, behaves exactly like that method.
2026-06-20 16:24:56 +08:00
*/
steer(content: ContentBlock[], options?: SendOptions): void
/**
* Inject in-session context (file-change notices, skill content, cron
* notifications, …): appends a `context/message` session event the next model
* request sees at its chronological position, rendered as tagged synthetic
* context rather than a user prompt. Does not run the model.
*
* 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 tagged-envelope rendering against
* current DeepSeek behavior; provider-specific mismatches belong in that
* adapter, not in the canonical session vocabulary.
*/
inject(content: ContentBlock[], options?: SendOptions): void
/**
simplify(agent): drop the unused public Agent.abort(), keep whenIdle()
The public Agent handle exposed abort() (step-only) and cancel() (queue-aware).
No production caller used abort() — ACP maps session/cancel to cancel(), and
lifecycle owners tear down via AgentHandle.dispose(); the loop's own stop paths
abort their per-step AbortController directly. So abort() is latent generality
that keeps a private loop mechanic public.
RFC-premise correction: the public-agent-stop-surface RFC proposed removing
whenIdle() too. Implementation found whenIdle() load-bearing — a real
quiescence primitive with a deliberate loop contract (settle-without-transition,
the replacement-turn race) and ACP test consumers; its proposed replacement
("observe the running->idle transition") is exactly the async-state race
AGENTS.md warns against. So only abort() is removed; whenIdle() stays. The RFC
is amended on the way to implemented/ to record the narrowed scope, and the new
AGENTS.md "RFCs are proposals, not golden truth" principle (PR1) gets its
worked example.
- Remove Agent.abort() from the interface + the ReactLoopAgent impl; the no-arg
'aborted' default goes with it (cancel() keeps its 'cancelled' default).
- Migrate tests: empty-queue abort() -> cancel(reason); the two review-fixes
tests whose subject is the in-flight step's AbortController drive that
controller directly via the private currentAbort field (cancel() would clear
the inbox and destroy the queued steering one of them proves survives a step
abort). The no-arg-default test is dropped (cancel()'s default is already
covered in cancel.spec.ts).
- Resulting public stop surface: cancel() + whenIdle(). Update agent/agent-loop
READMEs, architecture.md, core.md type-equiv, the extension cookbook, the
lifecycle RFC (short note), and the proposed ACP RFC.
Implements docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md
2026-06-21 05:50:39 +08:00
* Cancel ALL pending work for the agent. `cancel()` :
2026-06-20 16:24:56 +08:00
*
* - clears the queued FIFO (un-started prompts never run) and the steering
* FIFO (steering for the cancelled turn is dropped, not re-enqueued);
* - 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
/**
* Resolve once the agent has reached quiescence after settling out of
2026-06-21 10:03:30 +08:00
* `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
2026-06-21 10:21:32 +08:00
* 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.)
2026-06-20 16:24:56 +08:00
*
* "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 >
2026-06-22 06:47:20 +08:00
// 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.
2026-06-20 16:24:56 +08:00
}
```
2026-07-13 16:24:32 +08:00
`AgentStatus` is `'idle' | 'running' | 'disposed'` , and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `model?` . Persona belongs to `dsh-system-prompt` : an agent-scoped `deployment:persona` may shadow the global default.
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
## Interception decisions
Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision` /`PostToolDecision` in [tools.md ](tools.md ) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision` /`decision` /`continue` /`additionalContext` fields onto these; a native plugin returns them directly. They share one envelope for model-facing context, `HookContext` , which is `inject()` ed as a `context/message` and so carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt).
Source: [`packages/core/agent/src/types.ts` ](../../packages/core/agent/src/types.ts )
```ts type-equiv
interface HookContext {
content: ContentBlock[]
source: MessageSource
}
```
`agent/prompt-submit` returns a `PromptDecision` (allow a drained queued message — optionally rewriting its `content` or attaching `additionalContext` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected` ):
```ts type-equiv
type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'block'; reason: string }
```
`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 — the typed `/goal` pattern):
```ts type-equiv
type ContinuationDecision =
| { action: 'stop' }
| { action: 'continue'; reason?: HookContext }
```
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
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
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 )**.