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-21 01:11:55 +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 `ValueSchemaSpec` /`ParameterSchemaSpec` inference machinery 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-23 13:56:56 +08:00
| [session-query.md ](session-query.md ) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages |
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:
```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.
```ts type-equiv
/** 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-07-25 07:47:51 +08:00
Reasoning effort is another exact-route capability. The core brands identifiers but does not enumerate their values; each adapter owns the ordered set, display names, and optional deployment default.
```ts type-equiv
/** Adapter-owned identifier for one model's selectable reasoning effort. */
type ReasoningEffortId = Branded< 'ReasoningEffortId'>
```
```ts type-equiv
/** Display metadata for one adapter-owned reasoning effort. */
interface LlmReasoningEffortInfo {
/** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */
id: ReasoningEffortId
/** Human-readable effort name for selectors and diagnostics. */
name: string
/** Optional user-facing distinction from otherwise similar efforts. */
description?: string
}
```
```ts type-equiv
/** Selectable reasoning efforts for one exact provider/model route. */
interface LlmModelReasoningInfo {
/** Supported efforts in adapter-preferred display order. */
efforts: readonly LlmReasoningEffortInfo[]
/**
* Adapter-configured default materialized into requests when callers omit
* an effort. Absence preserves the provider's own default.
*/
defaultEffort?: ReasoningEffortId
}
```
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
2026-07-25 07:47:51 +08:00
/** Adapter-owned reasoning effort selected for this exact model. */
reasoningEffort?: ReasoningEffortId
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
2026-07-23 18:20:17 +08:00
* map the purpose to model-hidden transport metadata or purpose-specific
* generation policy. Ordinary conversation requests leave it unset.
2026-07-22 18:55:21 +08:00
*/
2026-07-23 18:20:17 +08:00
purpose?: 'compaction' | 'session-title'
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-25 22:59:45 +08:00
`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. After the waterfall, the loop prepares the exact model capability under the turn signal, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. The prepared call keeps one adapter registration through dispatch. `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
2026-07-25 07:47:51 +08:00
FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution).
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
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
2026-07-25 07:47:51 +08:00
* Provider, model, reasoning effort, and 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.
2026-07-19 12:25:40 +08:00
*/
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
2026-07-25 07:47:51 +08:00
reasoningEffort?: ReasoningEffortId
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
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` ,
2026-07-23 19:15:45 +08:00
* `assistant/message` , `tool/result` , `steering/message` ).
2026-07-19 12:25:40 +08:00
* 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-23 19:15:45 +08:00
The thirteen event variants (`turn/start` , `turn/end` , `step/start` , `step/end` , `user/message` , `prompt/blocked` , `assistant/chunk` , `assistant/message` , `tool/call` , `tool/result` , `steering/message` , `todo/write` , `request/header` ), the `deriveMessages()` projection rules, the `TurnTrigger` /`TurnEndReason` reasons, and the turn-enclosure invariant are on ** [session.md ](session.md )**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on ** [persistence.md ](persistence.md )**.
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
/**
2026-07-24 15:08:36 +08:00
* Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}.
2026-07-23 19:15:45 +08:00
* An omitted source attests direct human input as `{ kind: 'user' }` and may
* authorize policy consumers, so non-human producers must label their content.
2026-07-21 16:46:48 +08:00
*/
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-23 19:15:45 +08:00
/** Opaque JSON state retained on the durable message but hidden from the model. */
meta?: JsonValue
2026-07-21 16:46:48 +08:00
}
```
2026-07-10 14:32:44 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/** Options specific to durable synthetic context injection. */
2026-07-24 12:27:20 +08:00
interface InjectOptions {
/** Defaults to `{ kind: 'plugin', plugin: '' }` ; non-human producers should identify themselves. */
source?: MessageSource
/** Opaque JSON state retained on the durable message but hidden from the model. */
2026-07-10 14:32:44 +08:00
meta?: JsonValue
}
```
2026-07-24 13:52:25 +08:00
The advanced acceptance form makes every default explicit and rules out attached contexts on injection:
```ts type-equiv
/**
2026-07-24 15:08:36 +08:00
* Fully specified input for {@link Agent.send}. Unlike the intent-named
2026-07-24 13:52:25 +08:00
* helpers, this form applies no defaults: callers provide content, source,
* contexts, metadata (including explicit `undefined` ), target, and wakeup.
* The union excludes attached contexts from non-waking next-step injection.
*/
type ResolvedAgentInput = {
content: ContentBlock[]
source: MessageSource
meta: JsonValue | undefined
} & (
| { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] }
| { target: 'next-step'; wakeup: true; contexts: HookContext[] }
| { target: 'next-step'; wakeup: false; contexts: [] }
)
```
2026-07-24 12:27:20 +08:00
FIFO delivery methods return an opaque `AgentMessageId` , stable across that message's `agent/inbox/*` events. Injection returns an id but bypasses those events:
2026-07-23 19:15:45 +08:00
```ts type-equiv
/**
2026-07-24 12:27:20 +08:00
* Opaque id assigned to one accepted agent input. FIFO inputs carry the same id
* on their `agent/inbox/*` events; injection bypasses those events.
2026-07-23 19:15:45 +08:00
*/
2026-07-23 20:45:29 +08:00
type AgentMessageId = Branded< 'AgentMessageId'>
```
The `agent/inbox/*` live events carry one accepted message; injection bypasses the FIFOs and never appears on them:
```ts type-equiv
/**
2026-07-24 12:27:20 +08:00
* One accepted FIFO message, carried by the `agent/inbox/*` live events. `id`
2026-07-24 15:08:36 +08:00
* is the value returned by the accepting helper or {@link Agent.send},
2026-07-24 13:52:25 +08:00
* stable across this message's enqueue, dequeue, and discard events. Source
* defaults, when applicable, are already applied, so these are the exact values
* the item was accepted with.
2026-07-24 12:27:20 +08:00
* `steering` is true for an item drained between steps; otherwise it is claimed
* at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable
* model-hidden state that lands on the eventual `user/message` /
2026-07-23 22:33:12 +08:00
* `steering/message` , not live-event routing data.
2026-07-23 20:45:29 +08:00
*/
interface AgentMessage {
2026-07-24 15:08:36 +08:00
/** The id returned by the accepting helper or {@link Agent.send}. */
2026-07-23 20:45:29 +08:00
id: AgentMessageId
2026-07-23 19:15:45 +08:00
content: ContentBlock[]
source: MessageSource
contexts: HookContext[]
2026-07-24 12:27:20 +08:00
/** Whether the item joined the steering FIFO rather than the queued FIFO. */
2026-07-23 19:15:45 +08:00
steering: boolean
2026-07-24 12:27:20 +08:00
/** Whether the item wakes the driver or requests another step. */
2026-07-23 19:15:45 +08:00
wakeup: boolean
}
```
```ts type-equiv
/** Options for {@link Agent.cancel}. */
interface CancelOptions {
/**
* Preserve queued and steering inbox items instead of discarding them. The
* active turn is still aborted, but un-started and pending work survives for a
* later turn and no `agent/inbox/discard` fires.
*/
keepInbox?: boolean
2026-07-10 14:32:44 +08:00
}
```
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-07-24 13:52:25 +08:00
The structural `Agent` interface exposes four intent helpers plus the fully resolved acceptance method. The concrete driver implements the matrix once, and each helper supplies its fixed routing and defaults.
2026-07-23 19:15:45 +08:00
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-07-23 19:15:45 +08:00
/** The provider route and model this agent's requests use. */
2026-06-20 16:24:56 +08:00
readonly options: AgentOptions
2026-07-23 19:15:45 +08:00
/** The live session this agent drives; its log is the durable source of truth. */
2026-06-20 16:24:56 +08:00
readonly session: Session
2026-07-23 19:15:45 +08:00
/** The current lifecycle state, mirrored on every `agent/status` transition. */
2026-06-20 16:24:56 +08:00
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-24 12:27:20 +08:00
* Queue an ordinary message as its own FIFO-ordered turn and wake the driver.
* Content, resolved source, and attached contexts are detached, validated,
* and frozen together; invalid input throws synchronously before notification
* or enqueue.
* @param content - the prompt content blocks.
* @param options - source, attached contexts, and durable model-hidden meta.
2026-07-23 20:45:29 +08:00
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
2026-07-12 16:54:37 +08:00
*/
2026-07-24 15:08:36 +08:00
followup(content: ContentBlock[], options?: SendOptions): AgentMessageId
2026-06-20 16:24:56 +08:00
/**
2026-07-24 12:27:20 +08:00
* Queue an ordinary message without waking an idle driver. The item retains
* FIFO order and is claimed only after another input wakes the driver. A lone
* queued item leaves `whenIdle()` resolved.
2026-07-23 19:15:45 +08:00
* @param content - the prompt content blocks.
2026-07-24 12:27:20 +08:00
* @param options - source, attached contexts, and durable model-hidden meta.
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
2026-07-12 16:54:37 +08:00
*/
2026-07-24 12:27:20 +08:00
queue(content: ContentBlock[], options?: SendOptions): AgentMessageId
2026-06-20 16:24:56 +08:00
/**
2026-07-24 12:27:20 +08:00
* Submit steering into the running turn and request another step. An open turn
* records it at the next steering checkpoint before a request or continuation
* decision; policy may stop before another step. After turn close and its
* checkpoint, any remainder is queued for a later turn; terminal
* `agent/turn-stop` , cancellation, or disposal may discard it. Idle steering
* becomes a waking ordinary turn.
2026-07-23 19:15:45 +08:00
* @param content - the steering content blocks.
2026-07-24 12:27:20 +08:00
* @param options - source, attached contexts, and durable model-hidden meta.
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
2026-06-20 16:24:56 +08:00
*/
2026-07-24 12:27:20 +08:00
steer(content: ContentBlock[], options?: SendOptions): AgentMessageId
2026-06-20 16:24:56 +08:00
/**
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
2026-07-24 12:27:20 +08:00
* executing; then it waits FIFO until that batch settles and drains before
* turn close even when interrupted. Idle injection uses a one-shot turn and
* durability checkpoint. Disposal awaits idle checkpoints; flush failures
* report through `agent/error` . An omitted source defaults to
* `{ kind: 'plugin', plugin: '' }` .
2026-07-23 19:15:45 +08:00
* @param content - the injected context content blocks.
* @param options - source and durable model-hidden meta.
2026-07-24 12:27:20 +08:00
* @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events.
2026-06-20 16:24:56 +08:00
*/
2026-07-24 12:27:20 +08:00
inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId
2026-06-20 16:24:56 +08:00
/**
2026-07-24 13:52:25 +08:00
* Accept one fully specified input through the same snapshot and routing path
* as the four intent-named helpers. `next-turn` targets the ordinary FIFO;
* `next-step` /wakeup targets steering (falling back to an ordinary waking turn
* while idle); and `next-step` without wakeup injects durable context without
* running the model. Every field is mandatory and no source or routing default
* is applied. Invalid input throws synchronously before notification, enqueue,
* or append.
* @param input - the resolved content, attribution, context, metadata, and routing facts.
* @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable.
*/
2026-07-24 15:08:36 +08:00
send(input: ResolvedAgentInput): AgentMessageId
2026-07-24 13:52:25 +08:00
2026-07-24 12:27:20 +08:00
/**
* Clear queued and steering work — unless `keepInbox` — and abort the active
* turn. An effective call first emits `agent/cancel-requested` with the
* resolved typed cause. The first cause wins for the active turn, and
* `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause
* means `{ kind: 'user' }` . Idle cancellation is a no-op and does not arm
* later work. The active turn snapshots and freezes the cause.
2026-07-16 18:12:34 +08:00
* @param cause - the stable caller intent carried by the current turn signal.
2026-07-24 12:27:20 +08:00
* @param options - cancellation options; `keepInbox` preserves pending work.
2026-06-20 16:24:56 +08:00
*/
2026-07-24 12:27:20 +08:00
cancel(cause?: AgentCancelCause, options?: CancelOptions): 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-24 12:11:58 +08:00
The cause is a TypeScript-enforced same-process input. An active `TurnCancellation` holder copies its discriminant into the runtime-only `AbortSignal.reason` and is retired before `turn/end` publication; the frozen `AbortSignal.reason` remains readable after that retirement. `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-16 16:29:46 +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-23 19:15:45 +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 an injected `user/message` (plugin/goal source); `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape.
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
/**
2026-07-23 19:15:45 +08:00
* Model placement. Absent or `separate` records an independent injected
* `user/message` ; `prompt-prefix` prepends this context and a stable
2026-07-22 17:34:31 +08:00
* 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-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`
2026-07-23 02:53:43 +08:00
The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional final-content and UI callbacks. 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.
2026-06-20 16:24:56 +08:00
2026-07-21 01:11:55 +08:00
Its full fields, the `defineTool` /`ValueSchemaSpec` /`ParameterSchemaSpec` typed schema DSL, the `ToolExecution` /`ToolExecutionResult` waterfall shapes, and the tool-presentation UI vocabulary are on ** [tools.md ](tools.md )**.