mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into codex/project-instruction-files
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/persistence-catalog.md # docs/rfc/implemented/feature/2026-06-15-code-mode.md # docs/rfc/implemented/feature/2026-06-30-interception-seams.md # docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md # docs/tool-execution-pipeline.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-core/tests/agent-core.spec.ts # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.md # packages/core/session/src/index.ts # packages/core/tools/README.md # packages/core/tools/src/index.ts # pnpm-lock.yaml # scripts/gen-doc-graphs.ts # scripts/type-equiv.manifest.json
This commit is contained in:
@@ -39,21 +39,21 @@ interface ApprovalRequest {
|
||||
* UI answerer only answers for agents it owns) and receives the audit
|
||||
* events on its session log.
|
||||
*/
|
||||
agent: Agent
|
||||
readonly agent: Agent
|
||||
/** The tool the question is about (presentation and audit). */
|
||||
toolName: string
|
||||
readonly toolName: string
|
||||
/**
|
||||
* The exact tool call being decided, when the asker has one — lets a UI
|
||||
* attach the prompt to the tool call it already streamed.
|
||||
*/
|
||||
callId?: CallId
|
||||
readonly callId?: CallId
|
||||
/** The asker's human-readable explanation of WHY it is asking. */
|
||||
reason?: string
|
||||
readonly reason?: string
|
||||
/**
|
||||
* Aborting withdraws the question: the request settles `'cancelled'`
|
||||
* immediately and a late answer from a still-pending answerer is discarded.
|
||||
*/
|
||||
signal?: AbortSignal
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -16,9 +16,11 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
|
||||
| Sub-page | Owns |
|
||||
|---|---|
|
||||
| [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam |
|
||||
| [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context |
|
||||
| [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/pre-execute`/`tools/post-execute` pipeline |
|
||||
| [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly |
|
||||
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline |
|
||||
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
|
||||
| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts |
|
||||
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
|
||||
@@ -199,7 +201,7 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition`
|
||||
|
||||
### The request envelope: `LlmCallConfig` and the logged header
|
||||
|
||||
Requests are built by the loop, not shaped per call: the non-history half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt, the tool schemas in the assembly's canonical order (dsh-system-prompt's `toolOrder` config, lexicographic when unset), and the session prefix — is logged session state (`request/header` snapshot and delta events, [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling; the `agent/session-prefix` waterfall — fired once per loop instance — composes the request-only messages fronting the derived history (recorded as the header's `messagePrefix`) — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws.
|
||||
Requests are built by the loop, not shaped per call: the non-history half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt, the tool schemas in the authoritative returned assembly order (initially canonicalized by dsh-system-prompt's `toolOrder` config, or lexicographically when unset), and the session prefix — is logged session state (`request/header` snapshot and delta events, [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling; the `agent/session-prefix` waterfall — fired once per loop instance — composes the request-only messages fronting the derived history (recorded as the header's `messagePrefix`) — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws.
|
||||
|
||||
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.
|
||||
|
||||
@@ -266,12 +268,29 @@ interface Agent {
|
||||
readonly session: Session
|
||||
readonly status: AgentStatus
|
||||
|
||||
/** Queue a user message. Starts a turn when idle; otherwise waits for the next turn. */
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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}.
|
||||
* turn. Uses the same owned-value and synchronous-validation boundary as
|
||||
* {@link send}; when idle, behaves exactly like that method.
|
||||
*/
|
||||
steer(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
@@ -345,7 +364,7 @@ interface Agent {
|
||||
}
|
||||
```
|
||||
|
||||
`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The generated [events catalog](../cordis-catalog/events.md) owns the exact `agent/*` vocabulary; turn/step boundaries are durable `session/event` records, not `agent/*` emits.
|
||||
`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging. Persona is not an agent option: the `dsh-system-prompt` config supplies the global default, and an agent-scoped `deployment:persona` section may shadow it. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits.
|
||||
|
||||
## Interception decisions
|
||||
|
||||
@@ -378,6 +397,12 @@ type ContinuationDecision =
|
||||
| { action: 'continue'; reason?: HookContext }
|
||||
```
|
||||
|
||||
`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' }>
|
||||
```
|
||||
|
||||
`agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it):
|
||||
|
||||
```ts type-equiv
|
||||
|
||||
@@ -25,15 +25,15 @@ interface SessionHeader {
|
||||
* session is created. A persistence backend rejects any other version on load
|
||||
* (no migration — see the constant).
|
||||
*/
|
||||
version: number
|
||||
readonly version: number
|
||||
/** The session's id (mirrors the {@link Session}'s id). */
|
||||
id: SessionId
|
||||
readonly id: SessionId
|
||||
/** Unix epoch milliseconds when the session was created. */
|
||||
createdAt: number
|
||||
readonly createdAt: number
|
||||
/** Absolute working directory the session was created in (if any). */
|
||||
cwd?: string
|
||||
readonly cwd?: string
|
||||
/** The session this one was forked from (seed lineage), if any. */
|
||||
parentSession?: SessionId
|
||||
readonly parentSession?: SessionId
|
||||
/**
|
||||
* How many leading events were INHERITED via a seed rather than produced by
|
||||
* this session — the seed boundary. Set when a fork seeds a child with a
|
||||
@@ -43,7 +43,7 @@ interface SessionHeader {
|
||||
* harness can skip the inherited prefix when deriving the child's OWN script
|
||||
* (the seeded events are the parent's, not this child's model calls).
|
||||
*/
|
||||
seedLength?: number
|
||||
readonly seedLength?: number
|
||||
}
|
||||
```
|
||||
|
||||
@@ -54,7 +54,7 @@ Creating a `Session` through the store takes a `seed` (replay/fork an existing e
|
||||
```ts type-equiv
|
||||
interface CreateSessionOptions {
|
||||
/** Events to seed the new session with (replay/fork). */
|
||||
seed?: SessionEvent[]
|
||||
readonly seed?: readonly SessionEvent[]
|
||||
/**
|
||||
* Creation metadata. The store fills in `version`/`id` and defaults
|
||||
* `createdAt` to now; the caller supplies the storage-level fields (validated
|
||||
@@ -67,7 +67,12 @@ interface CreateSessionOptions {
|
||||
* length, not the original boundary — the caller must pass the persisted
|
||||
* boundary back. A fresh fork passes its actual seeded-prefix length.
|
||||
*/
|
||||
meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number }
|
||||
readonly meta?: {
|
||||
readonly cwd?: string
|
||||
readonly parentSession?: SessionId
|
||||
readonly createdAt?: number
|
||||
readonly seedLength?: number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
31
docs/core-data-structures/scope.md
Normal file
31
docs/core-data-structures/scope.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Scoped Registration
|
||||
|
||||
The [scope package](../../packages/core/scope) supplies the identity and carrier vocabulary that makes one registration context mean both per-agent visibility and shared lifetime ownership. It is a library primitive rather than a Cordis service; the [agent-scope runtime-design RFC](../rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer) owns the implementation rationale, while the package [README](../../packages/core/scope/README.md) owns the callable API and filtering semantics.
|
||||
|
||||
Source: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts).
|
||||
|
||||
## Identity and dispatch carrier
|
||||
|
||||
`ScopeKey` is an opaque object identity. The shipped loop uses the live `Agent` object as its own key, but the primitive never inspects the object.
|
||||
|
||||
```ts type-equiv
|
||||
type ScopeKey = object
|
||||
```
|
||||
|
||||
`Scoped<T>` is the compile-time brand on the opaque routing receiver returned by `scopeTarget(base, key)`. Scope-filtered event declarations require this carrier as their `this` type, while the real event subject remains an explicit argument.
|
||||
|
||||
```ts type-equiv
|
||||
type Scoped<T extends object> = object & { readonly [ScopedBrand]: T }
|
||||
```
|
||||
|
||||
## Owned registration context
|
||||
|
||||
`Scope` pairs the tagged registration context with two teardown surfaces. `rawDispose` preserves the exact Cordis disposer identity needed by an ordered composite effect; `dispose()` is the public shared quiescence boundary for direct and racing callers.
|
||||
|
||||
```ts type-equiv
|
||||
interface Scope {
|
||||
ctx: Context
|
||||
rawDispose: () => Promise<void> | void
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
@@ -6,13 +6,13 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind
|
||||
|
||||
## Provider registry
|
||||
|
||||
`ctx.skills` is a multi-provider registry. Providers can represent local directories, embedded plugin data, HTTP catalogs, or another source. Provider plugins register synchronously during `apply()`; remote initialization, authentication, and discovery are awaited by `list()`. The registry validates candidates, resolves duplicate skill names first-wins by rank/provider order/local order, and sorts the final summaries by `name` for deterministic consumers. A provider `list()` rejection is logged and skipped without caching the degraded catalog; malformed candidates still fail fast because they violate the provider contract.
|
||||
`ctx.skills` is a multi-provider registry. Providers can represent local directories, embedded plugin data, HTTP catalogs, or another source. Provider plugins register synchronously during `apply()`; remote initialization, authentication, and discovery are awaited by `list()`. Provider objects, lookup options, and candidates are readonly same-process contracts, so the registry borrows them instead of manufacturing defensive snapshots. The registry still validates semantic fields, resolves duplicate skill names first-wins by rank/provider order/local order, and sorts the final summaries by `name` for deterministic consumers. A provider `list()` rejection is logged and skipped without caching the degraded catalog; malformed candidates still fail fast because they violate the provider contract.
|
||||
|
||||
```ts type-equiv
|
||||
interface SkillProvider {
|
||||
name: string
|
||||
list(options: SkillLookupOptions): Promise<SkillCandidate[]>
|
||||
get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined>
|
||||
readonly name: string
|
||||
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]>
|
||||
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -28,7 +28,7 @@ The shipped local provider scans roots in rank order:
|
||||
| 400 | `user-dsh` | `<dshHome>/skills` |
|
||||
| 500 | `user-agents` | `<agentsHome>/skills` |
|
||||
|
||||
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child, and DeepSeek Harness no longer ships built-in system skills from the local provider. Additional built-ins can be supplied later by another provider.
|
||||
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider.
|
||||
|
||||
## Skill identity
|
||||
|
||||
@@ -44,13 +44,13 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | '
|
||||
|
||||
```ts type-equiv
|
||||
interface SkillSummary {
|
||||
name: string
|
||||
description: string
|
||||
whenToUse?: string
|
||||
disableModelInvocation?: boolean
|
||||
source: SkillSource
|
||||
provider: string
|
||||
resourceBase?: SkillResourceBase
|
||||
readonly name: string
|
||||
readonly description: string
|
||||
readonly whenToUse?: string
|
||||
readonly disableModelInvocation?: boolean
|
||||
readonly source: SkillSource
|
||||
readonly provider: string
|
||||
readonly resourceBase?: SkillResourceBase
|
||||
}
|
||||
```
|
||||
|
||||
@@ -58,10 +58,10 @@ interface SkillSummary {
|
||||
|
||||
```ts type-equiv
|
||||
interface SkillCandidate extends SkillSummary {
|
||||
rank: number
|
||||
locator: unknown
|
||||
path?: string
|
||||
metadata?: Record<string, unknown>
|
||||
readonly rank: number
|
||||
readonly locator: unknown
|
||||
readonly path?: string
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -69,16 +69,16 @@ interface SkillCandidate extends SkillSummary {
|
||||
|
||||
```ts type-equiv
|
||||
type SkillResourceBase =
|
||||
| { kind: 'directory'; path: string }
|
||||
| { kind: 'url'; url: string }
|
||||
| { kind: 'opaque'; description: string }
|
||||
| { readonly kind: 'directory'; readonly path: string }
|
||||
| { readonly kind: 'url'; readonly url: string }
|
||||
| { readonly kind: 'opaque'; readonly description: string }
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
interface SkillDefinition extends SkillSummary {
|
||||
content: string
|
||||
path?: string
|
||||
metadata?: Record<string, unknown>
|
||||
readonly content: string
|
||||
readonly path?: string
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -86,18 +86,18 @@ Runtime skills use the same complete shape and participate in the same first-win
|
||||
|
||||
```ts type-equiv
|
||||
type SkillRegistration = Omit<SkillDefinition, 'provider'> & {
|
||||
provider?: string
|
||||
readonly provider?: string
|
||||
}
|
||||
```
|
||||
|
||||
## Lookup and configuration
|
||||
|
||||
Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. If no git root is found, the local provider treats the supplied cwd itself as the project root.
|
||||
Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Providers receive the same readonly options object used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root.
|
||||
|
||||
```ts type-equiv
|
||||
interface SkillLookupOptions {
|
||||
cwd?: string | undefined
|
||||
signal?: AbortSignal | undefined
|
||||
readonly cwd?: string | undefined
|
||||
readonly signal?: AbortSignal | undefined
|
||||
}
|
||||
```
|
||||
|
||||
@@ -105,7 +105,7 @@ The registry owns only its discovery-cache bound. The local provider owns filesy
|
||||
|
||||
```ts type-equiv
|
||||
interface Config {
|
||||
collectCacheMaxEntries?: number
|
||||
readonly collectCacheMaxEntries?: number
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -12,37 +12,41 @@ A provider advertises its **start-time** features on a static descriptor the ser
|
||||
|
||||
```ts type-equiv
|
||||
interface SubagentCapabilities {
|
||||
outputSchema: boolean
|
||||
depthLimit: boolean
|
||||
toolFilter: boolean
|
||||
readonly outputSchema: boolean
|
||||
readonly depthLimit: boolean
|
||||
readonly toolFilter: boolean
|
||||
readonly persona: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## The start request
|
||||
|
||||
What a caller asks for when starting a subagent. The tool layer builds this from the model's `{ description, prompt }` plus its own config; the service validates the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The three optional fields (`outputSchema`, `maxDepth`, `toolFilter`) each gate on the matching `SubagentCapabilities` flag. `outputSchema` is an object-rooted JSON Schema within the subset `assertSupportedOutputSchema` (dsh-tools) enforces — a schema outside it is rejected loud at start; the in-process backends realize it with a forced `structured_output` capture tool (see the [driver README](../../packages/subagent/subagent-inprocess/README.md)).
|
||||
What a caller asks for when starting a subagent. The tool layer builds this from the model's `{ description, prompt }` plus its own config; the service validates the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The four optional fields (`outputSchema`, `maxDepth`, `toolFilter`, `persona`) each gate on the matching `SubagentCapabilities` flag — in-process backends realize `toolFilter` as a scoped `tools.restrict()` and `persona` as a scoped shadowing `deployment:persona` section, both composed in the child's creation window. `outputSchema` is an object-rooted JSON Schema within the subset `assertSupportedOutputSchema` (dsh-tools) enforces — a schema outside it is rejected loud at start; the in-process backends realize it with a forced `structured_output` capture tool (see the [driver README](../../packages/subagent/subagent-inprocess/README.md)).
|
||||
|
||||
```ts type-equiv
|
||||
interface SubagentStartRequest {
|
||||
prompt: ContentBlock[]
|
||||
parent: Agent
|
||||
signal?: AbortSignal
|
||||
agentOptions?: AgentOptions
|
||||
outputSchema?: StructuredOutputSchema
|
||||
maxDepth?: number
|
||||
toolFilter?: { allow?: string[]; deny?: string[] }
|
||||
readonly prompt: ContentBlock[]
|
||||
readonly parent: Agent
|
||||
readonly signal: AbortSignal
|
||||
readonly agentOptions?: AgentOptions
|
||||
readonly outputSchema?: StructuredOutputSchema
|
||||
readonly maxDepth?: number
|
||||
readonly toolFilter?: ToolRestriction
|
||||
readonly persona?: string
|
||||
}
|
||||
```
|
||||
|
||||
`signal` is the single cancellation channel before and after readiness. The [subagent composition-controls RFC](../rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the persona, live global-tool filter, absolute-depth, and visibility-not-authority rationale.
|
||||
|
||||
## The terminal result: `SubagentResult`
|
||||
|
||||
The outcome of a run, resolved by `SubagentRun.result`. `structured` is present iff the request carried an `outputSchema` AND the provider honored it. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success.
|
||||
The outcome of a run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success.
|
||||
|
||||
```ts type-equiv
|
||||
interface SubagentResult {
|
||||
output: ContentBlock[]
|
||||
structured?: unknown
|
||||
stopReason: SubagentStopReason
|
||||
readonly output: ContentBlock[]
|
||||
readonly structured?: unknown
|
||||
readonly stopReason: SubagentStopReason
|
||||
}
|
||||
```
|
||||
|
||||
@@ -60,37 +64,36 @@ interface SubagentStopReasonMap {
|
||||
|
||||
## A live run: `SubagentRun`
|
||||
|
||||
The handle the consumer holds while a child executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path to reach child quiescence (no leaked idle child / session). `result` does NOT reject on a child-level failure — a model/transport failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result; it rejects only on an infrastructure fault the seam cannot represent. `sendMessage` and `resume` are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it.
|
||||
The handle the consumer holds after a provider has established a ready child. The consumer awaits `result` and MUST `dispose` on every path to cancel remaining work and reach child quiescence. `result` does NOT reject on a child-level failure — a model/transport failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result; it rejects only on an infrastructure fault the seam cannot represent. `sendMessage` and `resume` are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it.
|
||||
|
||||
```ts type-equiv
|
||||
interface SubagentRun {
|
||||
readonly id: AgentId
|
||||
readonly result: Promise<SubagentResult>
|
||||
cancel(reason?: string): void
|
||||
dispose(): Promise<void>
|
||||
sendMessage?(content: ContentBlock[]): void
|
||||
resume?(content: ContentBlock[]): SubagentRun
|
||||
resume?(content: ContentBlock[]): Promise<SubagentRun>
|
||||
}
|
||||
```
|
||||
|
||||
## The provider seam: `SubagentProvider`
|
||||
|
||||
One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it.
|
||||
One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it. It describes conversation history only, not tool registrations, injected services, or authority inheritance.
|
||||
|
||||
```ts type-equiv
|
||||
interface SubagentProvider {
|
||||
readonly name: string
|
||||
readonly capabilities: SubagentCapabilities
|
||||
readonly inheritsParentContext: boolean
|
||||
start(request: SubagentStartRequest): SubagentRun
|
||||
start(request: SubagentStartRequest): Promise<SubagentRun>
|
||||
}
|
||||
```
|
||||
|
||||
The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events.md)). `subagent/end` carries `lastAssistantMessage` (the child's final `output`) on the settle path, so an observer sees WHAT the subagent produced without holding the run (absent when the run rejected at the infrastructure level — no result was produced). These are **observe-only** events: both are plain `emit`s (the `subagent/end` fires from a detached `.then` after the result settles and awaits no listener), so a subscriber observes but cannot change the run. Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it.
|
||||
`SubagentProvider.start()` and `ctx.subagents.start()` are the publication boundary: their promises fulfill only with a ready run. The service attaches result observation, emits `subagent/start`, and returns the same holder-owned run; a rejected start has already cleaned provider-owned partial resources and emits neither lifecycle event. For an in-process provider, a start listener can resolve the live child with `ctx.agents.get(info.id)`; a remote provider need not publish into the local registry. `subagent/end` carries `lastAssistantMessage` (the child's final `output`) on the settle path and reports `error` on infrastructure rejection. Both lifecycle events are observe-only emits with per-listener exception containment.
|
||||
|
||||
## In-process backends: depth and seed
|
||||
|
||||
The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same context via `ctx.agents.create`. Two pieces of vocabulary ride on the existing agent/session types rather than new core types:
|
||||
The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as an ordinary `Agent` in the same application. The provider creates it directly through `parent.ctx`, passes the required signal into the core creation transaction, and delegates quiescent disposal to the returned `AgentHandle`. Provider removal prevents new starts but does not revoke an accepted run. The child receives a flat new scope rather than inheriting the parent's registrations. Two pieces of vocabulary ride on the existing agent/session types rather than new core types:
|
||||
|
||||
- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). The seam owns it — the loop neither sets nor reads it — so a nested spawn reads its parent's depth from `parent.options.subagentDepth` and the `depthLimit` capability caps the tree by refusing a child whose depth would exceed `request.maxDepth`.
|
||||
- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). Only `undefined` means top level; every stored present value must be a non-negative safe integer. The seam owns it — the loop neither sets nor reads it — so a nested spawn validates its parent's stored depth, rejects a derived child depth outside the safe-integer domain, and applies a defined absolute `request.maxDepth` cap to that child.
|
||||
- **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded).
|
||||
|
||||
38
docs/core-data-structures/system-prompt.md
Normal file
38
docs/core-data-structures/system-prompt.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# System Prompt Assembly
|
||||
|
||||
The [system-prompt package](../../packages/core/system-prompt) owns the data exchanged between prompt contributors and one assembly call. The package [README](../../packages/core/system-prompt/README.md) documents registration, ordering, scoping, and rendering behavior; this page pins the literal cross-package shapes that plugins implement or pass.
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts).
|
||||
|
||||
## Assembly context
|
||||
|
||||
`AssembleContext` identifies the scope layer one assembly resolves. It is merge-extensible: `dsh-agent` adds the optional live `agent` field, and `assembleContextFor(agent)` sets that field and `scope` together.
|
||||
|
||||
```ts type-equiv
|
||||
interface AssembleContext {
|
||||
scope?: ScopeKey
|
||||
}
|
||||
```
|
||||
|
||||
## Tool-provider result
|
||||
|
||||
`ToolProviderResult.schemas` is the model-visible set for the current assembly. `knownNames` is the provider's pre-restriction name universe used to distinguish a configured-name typo from a known tool that is deliberately hidden in this scope.
|
||||
|
||||
```ts type-equiv
|
||||
interface ToolProviderResult {
|
||||
readonly schemas: readonly ToolSchema[]
|
||||
readonly knownNames?: readonly string[]
|
||||
}
|
||||
```
|
||||
|
||||
## Prompt sections
|
||||
|
||||
`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context.
|
||||
|
||||
```ts type-equiv
|
||||
interface PromptSection {
|
||||
readonly name: string
|
||||
readonly order: number
|
||||
readonly text: string | ((context: AssembleContext) => string)
|
||||
}
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
# Tools
|
||||
|
||||
The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine and `ToolSchema` as the model-facing wire shape. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the waterfall execution shapes, and the UI-presentation vocabulary.
|
||||
The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine and `ToolSchema` as the model-facing wire shape. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the guarded execution shapes, and the UI-presentation vocabulary.
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts)
|
||||
|
||||
@@ -81,18 +81,41 @@ type InferArgs<S extends SchemaSpec> = Simplify<
|
||||
|
||||
`defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs<typeof parameters>`, and the helper converts the spec to JSON Schema (`schemaSpecToJsonSchema`) for the wire and validates model-generated args (`validateArgs`) before the typed body runs. A mismatch throws `ToolArgsError` (`code: 'INVALID_ARGS'`), which the registry turns into an `isError` result so the model can self-correct. Why a custom DSL and not schemastery: tool parameters need JSON Schema (the LLM wire format), not validation/transformation — the lightweight DSL gives the best authoring DX with the smallest surface.
|
||||
|
||||
## Execution: the `tools/pre-execute` / `tools/post-execute` pipeline shapes
|
||||
Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input and validates only semantic requirements such as a positive finite `timeoutMs`; `schemas()` materializes the explicit model-facing projection at the model boundary so execution and presentation share one resolved definition without leaking callbacks onto the wire.
|
||||
|
||||
`ctx.tools.execute()` runs each call through a two-waterfall pipeline — `tools/pre-execute` (the allow/deny/ask gate) → core dispatch → `tools/post-execute` (inspect/replace the result, attach context) — the seams where sandbox, permission, hook, and plan-mode plugins gate or transform a call. The pending call is a `ToolExecution`; the outcome is a `ToolExecutionResult`.
|
||||
## `ToolRestriction` — one scope's live global filter
|
||||
|
||||
`ToolRestriction` applies only to the live deployment-global tool layer. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays scope-local tools. A deny-only filter admits later unlisted globals, while an allow-list excludes them.
|
||||
|
||||
```ts type-equiv
|
||||
interface ToolExecution {
|
||||
callId: CallId
|
||||
name: string
|
||||
interface ToolRestriction {
|
||||
readonly allow?: readonly string[]
|
||||
readonly deny?: readonly string[]
|
||||
}
|
||||
```
|
||||
|
||||
## Execution: extensible waterfalls plus monotonic policy
|
||||
|
||||
`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). The outcome is a `ToolExecutionResult`.
|
||||
|
||||
```ts type-equiv
|
||||
type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
interface ToolExecutionInput {
|
||||
readonly callId: CallId
|
||||
readonly name: string
|
||||
/** Parsed JSON arguments (unknown — tools validate their own input). */
|
||||
arguments: unknown
|
||||
readonly arguments: unknown
|
||||
/** The agent on whose behalf the call runs (set by the agent loop). */
|
||||
agent?: Agent
|
||||
readonly agent?: Agent
|
||||
/**
|
||||
* Opaque token of the enclosing transport execution, when one exists. Code
|
||||
* Mode sets this on SDK sub-dispatches so commit-style observers can wait for
|
||||
* the outer `run_code` outcome without receiving its live mutable execution.
|
||||
*/
|
||||
readonly parent?: ToolExecutionToken
|
||||
signal?: AbortSignal
|
||||
}
|
||||
```
|
||||
@@ -110,6 +133,21 @@ interface ToolRunContext extends ToolExecution {
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
interface ToolExecution extends ToolExecutionInput {
|
||||
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
|
||||
readonly token: ToolExecutionToken
|
||||
}
|
||||
```
|
||||
|
||||
`ToolExecutionToken` is a compile-time opaque fresh `Symbol` at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` materializes `arguments` as detached lossless JSON, assigns the token, and deep-freezes the accepted arguments. A non-JSON value is normalized to an error before policy. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are readonly throughout the waterfalls, while an around-dispatch wrapper may add, replace, or remove only optional `signal`. After the complete pipeline the registry freezes the execution and exposes its stable identity to `tools/result` observers.
|
||||
|
||||
A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it.
|
||||
|
||||
```ts type-equiv
|
||||
type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
interface ToolExecutionResult {
|
||||
callId: CallId
|
||||
@@ -140,7 +178,9 @@ interface ToolExecutionResult {
|
||||
}
|
||||
```
|
||||
|
||||
Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`:
|
||||
The registry materializes and freezes the final accepted result immediately before `tools/result`. Its content, structured error, additional context, and presentation metadata must round-trip losslessly through JSON; an invalid outcome becomes a JSON-safe `isError` result, so the observed live outcome is safe for the later durable `tool/result` append.
|
||||
|
||||
Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`:
|
||||
|
||||
```ts type-equiv
|
||||
type PreToolDecision =
|
||||
@@ -155,7 +195,7 @@ type PostToolDecision =
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] }
|
||||
```
|
||||
|
||||
Call `next()` to delegate to the default (allow / accept-unchanged), or return a decision to short-circuit. A `pre-execute` `deny` (or `ask`, which degrades to deny until the permission system lands) skips dispatch and yields an `isError` result; input rewrite is deliberately NOT offered on `PreToolDecision` (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC). A `post-execute` `accept` may replace the model-facing `content` (clean, because `tool/result` is logged after `execute()` returns); a `block` turns the call into an `isError` whose content is the corrective `feedback`. Core dispatch sits between the waterfalls as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn.
|
||||
Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` skips dispatch and yields an `isError` result. An `ask` resolves through the optional approval seam: only `allowed-once` proceeds, while every non-grant, missing channel/service, or agent-less request becomes a normalized denial. A registered `ToolGuard` then runs and can still impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The synchronous `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn.
|
||||
|
||||
## The structured-output schema subset
|
||||
|
||||
|
||||
Reference in New Issue
Block a user