|
|
@@ -0,0 +1,300 @@
|
|
|
+# 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*).
|
|
|
+
|
|
|
+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 `ToolCallPresentation` vocabulary that renders it, and the `SessionPersistence` seam that stores the event log are not — they live on the sub-pages below.
|
|
|
+
|
|
|
+| Sub-page | Owns |
|
|
|
+|---|---|
|
|
|
+| [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam |
|
|
|
+| [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` |
|
|
|
+| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/execute` waterfall |
|
|
|
+| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
|
|
|
+
|
|
|
+> 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.
|
|
|
+
|
|
|
+## 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.
|
|
|
+
|
|
|
+Source: [`packages/llm/src/brand.ts`](../../packages/llm/src/brand.ts)
|
|
|
+
|
|
|
+```ts type-equiv
|
|
|
+type Branded<B extends string> = string & { readonly [BRAND]: B }
|
|
|
+```
|
|
|
+
|
|
|
+The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function.
|
|
|
+
|
|
|
+## Content blocks and messages
|
|
|
+
|
|
|
+A conversation is `Message`s; a message is an array of typed **content blocks**. The block union derives from `ContentBlockMap`.
|
|
|
+
|
|
|
+Source: [`packages/llm/src/types.ts`](../../packages/llm/src/types.ts)
|
|
|
+
|
|
|
+```ts type-equiv
|
|
|
+interface ContentBlockMap {
|
|
|
+ 'text': TextBlock
|
|
|
+ 'reasoning': ReasoningBlock
|
|
|
+ 'tool-call': ToolCallBlock
|
|
|
+ 'tool-result': ToolResultBlock
|
|
|
+ 'image': ImageBlock
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+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?`), `ImageBlock` (`url`, `mimeType?`). `ContentBlock = ContentBlockMap[ContentBlockType]`.
|
|
|
+
|
|
|
+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 }
|
|
|
+ agent: { kind: 'agent'; agentId: 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)**.
|
|
|
+
|
|
|
+## The model request and result
|
|
|
+
|
|
|
+One model call is a fully-assembled `GenerateOptions`; the non-streaming result is `GenerateResult`.
|
|
|
+
|
|
|
+Source: [`packages/llm/src/types.ts`](../../packages/llm/src/types.ts)
|
|
|
+
|
|
|
+```ts type-equiv
|
|
|
+interface GenerateOptions {
|
|
|
+ model: string
|
|
|
+ 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[]
|
|
|
+ /** Assistant prefix continuation (prefill). */
|
|
|
+ prefill?: ContentBlock[]
|
|
|
+ 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
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```ts type-equiv
|
|
|
+interface GenerateResult {
|
|
|
+ message: Message
|
|
|
+ usage?: TokenUsage
|
|
|
+ finish: FinishReason
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+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>
|
|
|
+ strict?: boolean
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` that produces it (schema + `execute`) is on [tools.md](tools.md).
|
|
|
+
|
|
|
+## 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`:
|
|
|
+
|
|
|
+Source: [`packages/session/src/types.ts`](../../packages/session/src/types.ts)
|
|
|
+
|
|
|
+```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]
|
|
|
+ }
|
|
|
+}[T]
|
|
|
+```
|
|
|
+
|
|
|
+The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `usage`, `error`), 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)**.
|
|
|
+
|
|
|
+## 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.
|
|
|
+
|
|
|
+Source: [`packages/agent/src/types.ts`](../../packages/agent/src/types.ts)
|
|
|
+
|
|
|
+```ts type-equiv
|
|
|
+interface Agent {
|
|
|
+ readonly id: AgentId
|
|
|
+ readonly options: AgentOptions
|
|
|
+ readonly session: Session
|
|
|
+ readonly status: AgentStatus
|
|
|
+
|
|
|
+ /** Queue a user message. Starts a turn when idle; otherwise waits for the next turn. */
|
|
|
+ send(content: ContentBlock[], options?: SendOptions): void
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Steer a running turn: content is injected between steps of the current
|
|
|
+ * turn. When idle, behaves like {@link send}.
|
|
|
+ */
|
|
|
+ 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
|
|
|
+
|
|
|
+ /** Abort the in-flight step (if any); the turn ends with reason 'aborted'. */
|
|
|
+ abort(reason?: string): void
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Cancel ALL pending work for the agent — the narrower {@link abort} kills
|
|
|
+ * only the in-flight step. `cancel()`:
|
|
|
+ *
|
|
|
+ * - 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
|
|
|
+ * `running`, or immediately if it is already idle with no queued work. The
|
|
|
+ * quiescence signal a teardown awaits: `agent.abort()` then
|
|
|
+ * `await agent.whenIdle()` guarantees queued/running work has fully stopped
|
|
|
+ * before the caller proceeds (a closing ACP connection, a disposing UI
|
|
|
+ * plugin), rather than returning while the driver is still streaming or about
|
|
|
+ * to start a queued turn.
|
|
|
+ *
|
|
|
+ * "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.
|
|
|
+ *
|
|
|
+ * Distinct from disposal: `whenIdle()` observes the transition WITHOUT tearing
|
|
|
+ * the agent down. A consumer that owns the agent's lifecycle disposes it
|
|
|
+ * separately.
|
|
|
+ */
|
|
|
+ whenIdle(): Promise<void>
|
|
|
+
|
|
|
+ // TODO(sub-agents): spawn/fork seams — semantics deliberately deferred.
|
|
|
+ // The intended shape: a creation option referencing a parent agent
|
|
|
+ // (fork = seed the child Session with the parent's event log; spawn =
|
|
|
+ // fresh Session), with the child returned as an Agent handle so steer()
|
|
|
+ // and event subscription work uniformly. See docs/architecture.md.
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. The `agent/*` event taxonomy (lifecycle, turn/step boundaries, the `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy).
|
|
|
+
|
|
|
+## `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)**.
|