Merge remote-tracking branch 'origin/master' into codex/simp-prune-tools-prompt-surface

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/session/README.md
#	packages/core/session/tests/derived-cache.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-14 17:30:34 +08:00
562 files changed
+4438 -12565

No files matched your search

+23 -61
View File
@@ -1,15 +1,7 @@
/**
* Code Mode: the `run_code` tool and its dispatch bridge. The model writes a
* TypeScript program; the bridge hands it to `ctx.codeRuntime` with one async
* binding per end capability visible to the calling agent, then serializes
* every binding call through a per-run queue onto `ToolRegistry.execute()`.
* Sub-calls therefore traverse the complete pre/guard/around/post/final-result
* pipeline exactly like native calls and carry the outer execution's opaque
* token for correlation. The bridge logs each sub-dispatch as a
* `tool/code-dispatch` session event and returns only the program's curated
* output. The registry itself decides WHEN this tool exists (its `mode`
* config); this module owns only the tool and the bridge.
*
* Code Mode `run_code` transport. Programs call the registry's agent-visible
* tools through nested, sequential executions; each sub-dispatch is logged for
* reconstruction, while only the outer curated result enters model history.
* @module @deepseek-ai/dsh-tools/src/code-mode
*/
@@ -24,16 +16,11 @@ import type { ToolDefinition, ToolRegistry } from './index.ts'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* One bridged sub-dispatch from a `run_code` program: the parent
* `run_code` call id, the deterministic sub-call id
* (`<parent>:code:<n>`), the tool `name` with its JSON-normalized
* `arguments` — the exact value dispatched, normalized BEFORE dispatch,
* so this append can never fail on payload shape — whether the sub-call
* errored, and a bounded `resultSummary` of its model-facing text.
* Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
* model context; persistence and UIs get every call. Appended inside the
* parent `run_code`'s execution (the bridge drains its queue before
* returning), so the turn-enclosure invariant holds by construction.
* One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the
* deterministic sub-call id (`<parent>:code:<n>`), the tool `name` with its
* JSON-normalized `arguments` — the exact value dispatched, normalized before dispatch, so
* this append can never fail on payload shape — whether the sub-call errored, and a
* bounded `resultSummary` of its model-facing text.
*/
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
}
@@ -89,16 +76,11 @@ function summarize(text: string): string {
}
/**
* JSON-normalize one binding call's argument into TWO independent parses of
* the same canonical text: `dispatched` goes to the tool, `logged` to the
* `tool/code-dispatch` event — identical by construction (the runtime's
* structured-clone boundary is wider than JSON; the session log accepts only
* JSON), and separate objects, so a tool mutating its args can neither
* desync the log from what was dispatched nor re-poison the append. A value
* that does not survive the round-trip (`undefined` — the log rejects it as
* event data — `BigInt`, a circular structure, a bare function) rejects that
* one call BEFORE dispatch with a model-correctable error: nothing ever
* executes unlogged.
* JSON-normalize one binding call's argument into TWO independent parses of the same canonical
* text: `dispatched` goes to the tool, `logged` to the `tool/code-dispatch` event — identical
* by construction (the runtime's structured-clone boundary is wider than JSON; the session log
* accepts only JSON), and separate objects, so a tool mutating its args can neither desync the
* log from what was dispatched nor re-poison the append.
*/
function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
if (value === undefined) {
@@ -139,7 +121,7 @@ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
/**
* Build the `run_code` {@link ToolDefinition}: one required `code` parameter,
* executed through the dispatch bridge described in the module doc. The
* executed through the dispatch bridge described above. The
* registry reserves it as presentation infrastructure under non-native modes,
* outside the filterable global/scoped capability layers.
* @param registry - the owning registry (sub-calls go through its `execute`,
@@ -172,11 +154,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
exec.signal?.addEventListener('abort', onOuterAbort, { once: true })
let dispatches = 0
// The per-run serialization queue: every binding call chains onto the
// tail, so even `Promise.all` executes the underlying tool calls one at
// a time in submission order (the tool contract carries no
// concurrency-safety metadata yet). The fold keeps the tail non-rejecting
// so one failed dispatch never poisons the chain.
// The per-run serialization queue: every binding call chains onto the tail, so even
// `Promise.all` executes the underlying tool calls one at a time in submission order (the
// tool contract carries no concurrency-safety metadata yet).
let queue: Promise<void> = Promise.resolve()
const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
const turn = queue.then(() => {
@@ -211,11 +191,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
signal: runController.signal,
})
const text = textOf(result.content)
// Sub-call `additionalContext` is deliberately DROPPED here: the
// loop's buffering (append after the step's tool/results) has no
// safe analogue from inside a running run_code — injecting now
// would break tool-call/result adjacency. Deferred until a real
// hook needs it through Code Mode.
// Sub-call `additionalContext` is deliberately DROPPED here: the loop's buffering
// (append after the step's tool/results) has no safe analogue from inside a running
// run_code — injecting now would break tool-call/result adjacency.
exec.agent?.session.append('tool/code-dispatch', {
parentCallId: exec.callId,
subCallId,
@@ -266,18 +244,8 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
signal: runController.signal,
})
} finally {
// Quiescence before returning, whether the runtime fulfilled or
// REJECTED (a backend that starts a binding call and then throws
// must not leak a live sub-dispatch past this settlement): fire
// the run-scoped abort (cancelling an in-flight sub-dispatch,
// abandoning queued ones), then await the queue's drain — an
// aborted sub-call still settles and logs its event INSIDE the
// open turn; nothing can append after we return. `queue` is the
// FOLDED tail (every link swallows its rejection into undefined),
// so this await cannot itself reject — an abandoned queued call
// can never mask the runtime's own failure, returned or thrown;
// rejections surface only on the per-call promises the program
// holds.
// Abort sub-dispatches and drain the folded queue before closing the turn.
// Binding failures remain observable through their individual promises.
runController.abort('run_code settled')
await queue
}
@@ -297,13 +265,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
exec.signal?.removeEventListener('abort', onOuterAbort)
}
},
// The program IS the title, the way command tools title their cards with
// the command: an execute-card's title is the one slot an ACP client
// always shows (Zed's execute cards render no body content and no raw
// input without a real terminal attached), so anywhere else the code
// would be invisible. Multi-line titles are the execute-card idiom —
// capable clients render them whole; others truncate to the first line
// and still hold the full program in rawInput.
// ACP execute cards use the program as their visible title.
presentCall: args => ({
card: 'generic',
title: args.code,
+55 -269
View File
@@ -1,18 +1,6 @@
/**
* Tool registry and execution pipeline. Plugins register tools; the registry
* feeds schemas into the system prompt, and `execute()` dispatches each call
* through `tools/pre-execute` (the extensible allow/deny gate) → monotonic
* registered guards → `tools/execute` (an around-dispatch wrapper for
* timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the
* result, attach context) → the observe-only `tools/result` notification.
*
* The registry also owns HOW its tools are presented to the model — its
* `mode` config: `'native'` (every tool as a wire function definition,
* today's behavior and the default), `'code'` (the registry's canonical wire
* contribution is one tool, `run_code`, plus a generated TypeScript SDK prompt section), or
* `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and
* `ts-types.ts` (the SDK codegen); design in the Code Mode RFC.
*
* Tool registry, model presentation modes, and pre/guard/around/post/result
* execution pipeline.
* @module @deepseek-ai/dsh-tools
*/
@@ -83,79 +71,34 @@ declare module 'cordis' {
interface Events {
/**
* Waterfall BEFORE a tool runs — the gate where sandbox, permission, and
* hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners
* receive `(exec, next)`: call `next()` to delegate to the default (allow),
* or return a {@link PreToolDecision} without calling `next()` to
* short-circuit. A `deny` skips dispatch and yields an `isError` result; the
* tool body never runs. Input rewrite is deliberately NOT offered here (see
* {@link PreToolDecision}); `ask` is serviced by the `ctx.approval` seam
* when one is mounted, and degrades to deny otherwise.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a
* listener registered through `agent.ctx` fires only for that agent's
* calls, while a plain plugin listener fires for every call (including
* agent-less ones, which dispatch subject-less).
* Allow, deny, or ask before dispatch. `next()` delegates to allow; missing
* approval support turns `ask` into denial.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
* @param exec - the pending call (name, parsed arguments, caller agent).
* @mode waterfall
*/
'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
/**
* Around-dispatch waterfall wrapping the registry's core tool dispatch,
* between the `tools/pre-execute` gate and the `tools/post-execute` seam. A
* listener receives `(exec, next)`: call `next()` to delegate to dispatch
* (returning its {@link ToolExecutionResult}, optionally wrapped), or return a
* replacement result without calling `next()` to short-circuit dispatch. The
* base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or
* unknown tool) is already normalized to an `isError` result by the time a
* listener's `await next()` returns, so a wrapper never sees a raw throw from
* the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can
* set or replace the one mutable field, `exec.signal` (e.g. with a per-call
* deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity
* (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the
* pipeline so a wrapper cannot change which tool and scope the pipeline
* accepted. (Cordis `next()` ignores passed arguments and re-invokes
* downstream with the shared payload, so a wrapper changes `exec.signal` in
* place rather than passing a new object to `next()`.)
* Multiple listeners compose by registration order — an outer one wraps the
* inner ones plus dispatch.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
* `exec.agent` — a listener registered through `agent.ctx` wraps only that
* agent's calls; a plain plugin listener wraps every call (including
* agent-less ones, which dispatch subject-less).
* Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns
* a normalized result; wrappers may change only `exec.signal`, while call
* identity remains immutable.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
* @mode waterfall
*/
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
/**
* Waterfall AFTER a tool runs — where hook plugins inspect the result and
* accept it (optionally REPLACING the model-facing content, and/or attaching
* `additionalContext` for the next request) or block it with corrective
* `feedback` (Claude Code's `PostToolUse`). Listeners receive
* `(exec, result, next)`: call `next()` to delegate to the default (accept
* unchanged), or return a {@link PostToolDecision} to override. Core tool
* dispatch runs earlier as the base `next()` of the `tools/execute`
* waterfall, all inside `execute`'s outer try/catch (and the tool body keeps
* its own inner try/catch, so a thrown tool still reaches `post-execute` as an
* `isError` result).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
* `exec.agent` — a listener registered through `agent.ctx` fires only for
* that agent's calls; a plain plugin listener fires for every call
* (including agent-less ones, which dispatch subject-less).
* Accept, replace, enrich, or block a normalized dispatch result. `next()`
* accepts it unchanged; thrown tools still reach this seam as errors.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
* @param exec - the call that just ran (name, parsed arguments, caller agent).
* @param result - the dispatch outcome a listener may accept, replace, or block.
* @mode waterfall
*/
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
/**
* Synchronous notification of the authoritative FINAL tool outcome, after the
* complete pre/execute/post pipeline, final lossless-JSON validation, and
* outer error normalization.
* Unlike the three waterfalls, this seam cannot transform the result: each
* listener receives the now-frozen execution object and a deep-frozen result
* snapshot; listener failures are contained and logged, and
* {@link ToolRegistry.execute} still returns the outcome.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by
* `exec.agent`, using the same carrier as the pipeline.
* Observe the frozen, lossless-JSON final outcome. Listener failures are contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.
* @param exec - the execution object that traversed the pipeline.
* @param result - a deep-frozen snapshot of the final returned result.
* @mode emit
@@ -174,18 +117,10 @@ declare module 'cordis' {
}
}
// TODO(review): revisit these shapes when concurrency metadata becomes useful
// TODO(concurrency): revisit these shapes when concurrency metadata becomes useful
// (for example, a read-only hint that would permit safe parallel execution).
/**
* What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the
* common case (model-facing content only); the object form additionally attaches
* a tool-private `meta` presentation payload that the registry threads onto the
* `tool/result` session event and hands back to the tool's `presentResult`.
* `meta` is opaque to the core (`unknown` — the tool owns and narrows its shape),
* and MUST be JSON-serializable: it persists on the durable log (the session
* enforces this at `append`), so replay reproduces the card.
*/
/** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */
export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
/** A registered tool: its schema plus the execution function. */
@@ -236,12 +171,7 @@ export interface ToolResult {
declare const toolExecutionTokenBrand: unique symbol
/**
* Opaque identity for one trip through the tool pipeline. Nested
* transports carry the enclosing execution's token instead of its live object,
* so observe-only result listeners can correlate calls without gaining a
* mutation path into an outer around-dispatch wrapper.
*/
/** Opaque call identity that permits correlation without exposing mutable execution state. */
export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
/**
@@ -307,14 +237,8 @@ export interface ToolExecutionResult {
*/
error?: ToolErrorInfo
/**
* Extra model-facing context a `tools/post-execute` listener attached for the
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
* of this call's `content` — `content`/`feedback` shape the tool RESULT, but
* `additionalContext` is a SEPARATE `context/message`. A step can carry
* multiple tool calls, so the loop BUFFERS every call's `additionalContext`
* and appends them only AFTER all `tool/result`s for the step, keeping
* tool-call/result adjacency intact. Carried on the result purely to ferry it
* from `execute()` up to the loop's per-step buffer.
* Model-facing context for the next request, separate from this tool result.
* The loop buffers it until all step results are logged, preserving pairing.
*/
additionalContext?: HookContext
/**
@@ -327,19 +251,10 @@ export interface ToolExecutionResult {
}
/**
* The decision a `tools/pre-execute` listener returns for one pending call.
* Maps onto Claude Code's `PreToolUse` `permissionDecision`.
*
* - `allow` proceeds to dispatch. (Input rewrite — changing `exec.arguments` —
* is deliberately NOT offered: `tool/call` and `assistant/message` are logged
* BEFORE execution and live consumers, e.g. the ACP bridge and `dsh-tool-bash`
* presentation, read the pre-execution arguments, so an execution-only rewrite
* would desync the UI from what RAN. That consistency redesign is its own
* `proposed` RFC; `TODO(pre-tool-input-rewrite)` anchors it at the call site.)
* - `deny` skips dispatch; the loop records an `isError` result carrying `reason`.
* - `ask` is the permission-prompt intent: serviced as a one-shot decision by
* the `ctx.approval` seam when one is mounted (`allowed-once` proceeds to
* dispatch; every other outcome denies), degrading to `deny` when none is.
* Pre-dispatch decision. `allow` runs the call; `deny` materializes an error;
* `ask` runs only after an approval service returns `allowed-once` and otherwise
* denies. Input rewriting is excluded because arguments are already logged and
* presented.
*/
export type PreToolDecision =
| { kind: 'allow' }
@@ -347,16 +262,8 @@ export type PreToolDecision =
| { kind: 'ask'; reason?: string }
/**
* The decision a `tools/post-execute` listener returns for one finished call.
* Maps onto Claude Code's `PostToolUse` decision.
*
* - `accept` keeps the call successful; optional `content` REPLACES the
* model-facing result (clean: `tool/result` is logged AFTER `execute()`
* returns, so a replaced result is the single source of truth for both derived
* history and UI). Optional `additionalContext` rides to the next request.
* - `block` turns the call into an `isError` result whose content is the
* corrective `feedback` (the model is told the call was rejected and why),
* optionally also attaching `additionalContext`.
* Post-dispatch decision: accept or replace content, attach context for the next
* request, or block by turning corrective feedback into an error result.
*/
export type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext }
@@ -399,35 +306,17 @@ export type ToolPresentationMode = 'native' | 'code' | 'both'
/** Plugin config: how the registered tools are presented to the model. */
export interface Config {
/**
* The presentation mode. `'native'` (the default) contributes every
* visible end capability as a native wire function definition. Under
* `'code'` this registry contributes exactly ONE wire tool,
* `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a
* TypeScript API the program calls. `'both'` contributes every native
* definition AND `run_code` + the SDK section. Non-native modes require a
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
* or mismatched runtime rejects every prompt assembly with an actionable
* error (misconfiguration fails loud, before any model request). A
* configured `systemPrompt.toolOrder` naming native tools likewise rejects
* every assembly under `'code'` (those names are no longer contributed) —
* a deployment switching modes updates its order config or drops it.
* Model presentation. `native` (default) sends every visible schema; `code`
* sends only `run_code` plus a generated SDK prompt; `both` sends both forms.
* Code modes require a TypeScript runtime and fail prompt assembly when it is
* absent or mismatched. Under `code`, native names in `toolOrder` are invalid.
*/
mode?: ToolPresentationMode
}
/**
* A per-scope restriction over the GLOBAL tool surface, registered via
* {@link ToolRegistry.restrict}. `allow` keeps only the listed global tools;
* `deny` removes the listed ones; both present = allow first, then deny.
* Restrictions never touch scoped registrations — a tool registered through
* the same scope is merged after the global filter (which is what keeps e.g. a
* structured-output capture tool alive under an allow-list). The readonly
* filter values compile to private sets at registration, but resolution uses the live global registry:
* a later global name passes a deny-only filter unless explicitly denied and
* fails an allow-list unless explicitly allowed. The
* reserved `run_code` presentation transport is likewise outside capability
* filtering, and naming it explicitly is rejected. Multiple restrictions on
* one scope compose by intersection: every one must admit.
* Per-scope filter over global tools. Restrictions intersect and do not affect
* scoped registrations or the reserved Code Mode transport.
*/
export interface ToolRestriction {
/** Global tool names that stay visible; everything else is removed. */
@@ -468,26 +357,8 @@ interface ToolGuardRegistration {
}
/**
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
* loop executes calls through the `tools/pre-execute` → guards →
* `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The
* registry contributes its schemas into the system-prompt assembly — WHICH
* schemas is governed by its `mode` config
* (see {@link Config.mode}); under a non-native mode it also owns the reserved
* `run_code` presentation transport and the `tools:sdk` prompt section.
*
* Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a
* plain plugin context is GLOBAL (visible to every agent); one through a
* scoped context (`agent.ctx`) is filed in that scope's layer — visible to
* that agent alone, disposed with the scope, and SHADOWING a global tool of
* the same name for that agent (most-specific-wins; within one layer a
* duplicate name still throws). {@link restrict} masks the global layer per
* scope. One private visibility resolver feeds the registry's prompt
* contribution, {@link get}, and {@link execute} — and, under a non-native
* mode, the SDK section and `run_code`'s bindings — so those registry-owned
* presentation and dispatch paths agree. An expert `system-prompt/assemble`
* listener may deliberately replace the final wire composition and owns any
* resulting divergence.
* Tool registry and execution pipeline. Scoped registrations shadow globals;
* one visibility resolver feeds presentation, lookup, and dispatch.
*/
export class ToolRegistry extends Service {
static inject = ['systemPrompt']
@@ -525,13 +396,7 @@ export class ToolRegistry extends Service {
ctx.systemPrompt.section({
name: 'tools:sdk',
order: SDK_SECTION_ORDER,
// A lazy thunk over the live registry, per assembly CONTEXT:
// regenerated at each assembly over the CALLING SCOPE's visible set
// (scoped tools join, restricted globals vanish — the SDK declares
// exactly what that agent's programs can call), in lexicographic
// tool order, so an unchanged tool set renders byte-identical text
// (prefix-cache-friendly) and a mid-session registration surfaces
// exactly like a native-mode tool change.
// Regenerate from the calling scope's visible tools in stable order.
text: (context) => {
this.requireCodeRuntime()
return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME))
@@ -541,22 +406,8 @@ export class ToolRegistry extends Service {
}
/**
* The registry's contribution to the wire tool list, per {@link Config.mode},
* as ONE SCOPE sees it (scoped layer joins, shadowing and restrictions
* applied — {@link schemas}). Because `PromptAssembly.tools` is what the
* loop's request header snapshots, the mode's collapse is logged and
* reconstructable for free. Under a non-native mode this is also the loud
* misconfiguration gate: no usable code runtime → every assembly rejects
* before any model request.
*
* The `knownNames` universe distinguishes the two ways a tool can be off
* the wire: a per-scope RESTRICTION is runtime state, so `knownNames` stays
* pre-restriction and a restricted-away tool in `toolOrder` is a normal
* absence — while the MODE collapse is deployment config, so under
* `mode: 'code'` the universe is `[run_code]` and a `toolOrder` naming a
* native tool is dead configuration that fails every assembly loud. Under
* `mode: 'both'`, the provider adds the reserved transport to the
* capability-only known-name universe for `toolOrder` validation.
* Build one scope's wire schemas and names for prompt-order validation.
* Restrictions do not make known tools invalid, but a mode collapse does.
*/
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
const view = this.view(scope)
@@ -594,23 +445,10 @@ export class ToolRegistry extends Service {
}
/**
* Register a tool. The layer is decided by the CALLING context: a plain
* plugin context registers globally; a scoped context (`agent.ctx`)
* registers into that scope's layer — visible to that agent alone, disposed
* with the scope, and shadowing a same-named global tool for that agent.
* Throws if the SAME layer already has the name (cross-layer name twins are
* the shadowing feature, not an error; the global-duplicate message names
* `agent.ctx` as the per-agent alternative), or if a non-native mode reserves
* the `run_code` name for its presentation transport. The visible schema set
* flows into prompt assembly automatically. Definitions are trusted typed
* same-process contributions; JSON materialization happens when the schema or
* result reaches its model/log boundary. Emits `tools/change` on
* register/unregister.
* @param definition - the tool's schema plus its execute (and optional
* presentation) functions.
* @returns the disposer that unregisters the tool. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
* yield it directly — exact identity nests the teardown in order.
* Register globally or in the calling agent scope. Scoped tools shadow
* globals; duplicates within one layer and the reserved `run_code` name fail.
* @param definition - the tool schema, execution, and optional presentation functions.
* @returns the exact disposer that unregisters the tool.
*/
register(definition: ToolDefinition): () => void {
const scope = scopeOf(this.ctx)
@@ -631,52 +469,26 @@ export class ToolRegistry extends Service {
: `tool "${name}" is already registered in this scope`)
}
layer.set(name, definition)
// Yield the rollback BEFORE emitting `tools/change`: a generator effect
// collects each yielded disposer before the next step runs, so a throwing
// `tools/change` listener removes the tool instead of leaking it (a leak
// would wedge the duplicate-name check until restart). The duplicate
// throw above fires before any mutation — it leaks nothing.
// Install rollback before notifying listeners.
yield () => {
layer.delete(name)
// An emptied scope layer is dropped so a disposed scope leaves no
// residue keyed by its (dead) key.
// Drop empty scope layers.
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
this.ctx.emit('tools/change')
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.register()')
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Cleanup is synchronous because this
// registration installs only synchronous state and notifications.
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
/**
* Restrict the GLOBAL tool surface for the calling scope. Must be called
* through a scoped context (`agent.ctx`) — restricting "everyone" is not a
* thing (throw), and an empty filter (neither `allow` nor `deny`) is a no-op
* that can only be a bug (throw — the materialized-empty-config trap).
* Validates every listed name against the CURRENT global end-capability
* universe and throws on an unknown or scope-local name (fail loud
* beats a typo silently filtering nothing) — register restrictions after the
* global tools they mask exist (the agent-creation `setup` window satisfies
* this). A non-native mode's reserved `run_code` presentation transport is
* not a filterable capability; naming it explicitly throws, while omitting
* it from an allow-list cannot remove it. The readonly arrays are compiled to
* private sets at registration. Resolution still uses the live global registry, so a later
* global name passes a deny-only filter unless named and fails an allow-list
* unless named. Multiple restrictions compose by intersection. Scoped
* registrations are merged after restrictions and therefore remain visible.
* Disposed with the calling fiber (revocable independently); emits
* `tools/change`.
* Restrict global tools for the calling agent scope. Empty filters, unknown
* names, scope-local names, and reserved transport names fail. Restrictions
* intersect; scoped registrations remain visible.
* @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).
* @returns the disposer that lifts this restriction. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
* yield it directly — exact identity nests the teardown in order.
* @returns the exact disposer that lifts this restriction.
*/
restrict(filter: ToolRestriction): () => void {
const scope = scopeOf(this.ctx)
@@ -714,12 +526,7 @@ export class ToolRegistry extends Service {
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.restrict()')
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Cleanup is synchronous because this
// registration installs only synchronous state and notifications.
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
@@ -841,15 +648,8 @@ export class ToolRegistry extends Service {
}
/**
* The model-facing schemas of everything `scope` can see — exactly the
* fields (`name`, `description`, `parameters`) this registry contributes to
* system-prompt assembly before its expert transformation waterfall.
* Constructed EXPLICITLY rather than by stripping
* known non-schema members: a `ToolDefinition` also carries `execute` and the
* optional `presentCall`/`presentResult` UI callbacks, and those (especially
* the functions) must never leak into a model request. An allowlist can't
* drift when a new non-schema member is added to the definition; a denylist
* (rest-destructure) would silently leak it.
* Project visible definitions onto the allowlisted model-facing schema fields,
* excluding execution and presentation callbacks.
* @param scope - the viewing scope (the agent); omitted = the global view.
* @returns one deep-cloned schema per visible tool.
*/
@@ -868,27 +668,13 @@ export class ToolRegistry extends Service {
}
/**
* Execute one tool call through the `tools/pre-execute` → guards →
* `tools/execute` (around dispatch) → `tools/post-execute` → `tools/result`
* pipeline. `pre-execute` is the extensible gate
* (allow/deny/ask), `tools/execute` wraps core dispatch (a timeout/retry/metrics
* seam), and `post-execute` is the inspect/transform seam; core dispatch sits
* as the base `next()` of the `tools/execute` waterfall. The whole thing is
* wrapped in one outer try/catch so a throwing listener (in any waterfall)
* becomes an `isError` result instead of failing the turn; the tool body ALSO
* keeps its own inner try/catch, so a thrown tool becomes an `isError` result
* that `tools/execute` and `post-execute` listeners can still inspect. If the
* tool is not registered (or not visible to the calling agent — a
* restricted-away global is exactly as absent as a nonexistent one), the
* result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown
* {@link HarnessError} surfaces its `{ name, code }` on the result. Before
* the final observe-only notification, the authoritative outcome is
* materialized as a detached lossless-JSON snapshot; an invalid outcome is
* normalized to an error.
* Execute through pre-policy, guards, around-dispatch, post-policy, and final
* notification. Tool and listener failures resolve as materialized error
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
* the same lossless, frozen snapshot final observers receive.
* @param exec - the typed same-process call input. The registry assigns its
* correlation token before policy begins.
* @returns the materialized final result after every waterfall; listener and
* tool failures resolve as `isError` results rather than rejections.
* @returns the materialized final result.
*/
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
const token = createExecutionToken()
+5 -27
View File
@@ -1,31 +1,9 @@
/**
* Structured-output JSON Schema subset: the vocabulary a caller uses to demand
* a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`)
* or a workflow `agent()` call.
*
* This is deliberately NOT full JSON Schema. The schema travels verbatim to the
* model as a forced tool's `parameters`, and the value the model produces is
* validated here — so every accepted keyword must be one this module actually
* enforces. Accepting a keyword we don't enforce would validate less than the
* schema promises (accepted-then-ignored), so anything outside the subset is
* REJECTED LOUD by {@link assertSupportedOutputSchema} instead. The subset:
*
* - `type` — a single string (`object`/`array`/`string`/`number`/`integer`/
* `boolean`/`null`); type ARRAYS (`["string","null"]`) are rejected.
* - `properties`/`required`/`additionalProperties` (boolean) on objects; every
* `required` key must be declared in `properties`. `additionalProperties`
* absent keeps standard JSON Schema semantics (extra keys allowed).
* - `items` on arrays (absent ⇒ any JSON items).
* - `enum` (non-empty, scalars only) and `const` (scalar) on scalar types.
* - Annotations `description`/`title`/`default`/`examples` are allowed and
* ignored (they constrain nothing), except that they must still be JSON data
* — the schema is serialized onto the wire, so a non-JSON annotation would be
* silently mangled.
*
* Values checked by {@link validateStructuredValue} are expected to be plain
* host-realm JSON data (model tool-call arguments are parsed wire JSON; a
* caller holding foreign-realm data materializes it first).
*
* Structured-output JSON Schema subset for subagents and workflows. It supports
* one scalar `type`; object `properties`/`required`/boolean
* `additionalProperties`; array `items`; scalar `enum`/`const`; and JSON-valued
* annotations. Unsupported or misplaced keywords reject rather than being
* accepted without enforcement, and structured-output roots must be objects.
* @module dsh-tools/json-schema
*/
+8 -33
View File
@@ -1,20 +1,7 @@
/**
* Tool render-intent vocabulary: the provider-neutral types a tool declares via
* `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say
* how ONE of its calls renders in a UI (an editor's tool-call card, a CLI log
* line). A UI bridge switches on the `card` tag to map each intent to its own
* wire shape, so a UI never special-cases tool names.
*
* This is the UI-facing surface of `dsh-tools`, kept separate from the registry
* and execution core in `index.ts`: this module owns ONLY presentation
* vocabulary and references none of the execution types, so the dependency runs
* one way (`index.ts` imports these views for the `ToolDefinition` method
* signatures). The opaque `meta` presentation channel is execution plumbing and
* lives with the registry in `index.ts`, not here.
*
* See the render-intent-union RFC
* (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
*
* `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say how one of its calls
* renders in a UI (an editor's tool-call card, a CLI log line).
* @module @deepseek-ai/dsh-tools/src/presentation
*/
@@ -56,14 +43,8 @@ export interface FileDiff {
}
/**
* How a tool wants ONE of its calls shown in a UI (an editor's tool-call card, a
* CLI log line) BEFORE the result is known — the *pending* state. A `card`-tagged
* discriminated union: a tool declares its render INTENT once and a UI bridge
* switches on `card` to map it to the bridge's own wire shape. Provider-neutral —
* the tool owns its presentation, so a UI never special-cases tool names.
*
* Returned by `ToolDefinition.presentCall`. See the render-intent-union
* RFC (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
* Provider-neutral pending-call presentation. Tools declare one tagged intent;
* UI bridges map it without special-casing tool names.
*/
export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView
@@ -186,16 +167,10 @@ export interface TerminalResultView {
}
/**
* A completed file mutation rendered as an inline diff card, the *result-time*
* analogue of {@link DiffCallView}. Set by a tool whose `execute` applied a file
* change (e.g. `write`, `edit`): `diffs` are the change to show — typically the
* APPLIED hunks computed from the before/after content (one entry per hunk, each
* with surrounding context lines), so the editor shows the real change in place;
* a tool with no before-image (e.g. a file create) may instead give a whole-file
* diff (`oldText: null`). A `tool_call_update`'s content REPLACES the call's
* content in an editor, so a mutation tool returns this even when it duplicates
* the call-time snippet — otherwise the model-facing result text would replace
* (clobber) the pending diff card.
* A completed file mutation rendered as an inline diff card, the result-time
* analogue of {@link DiffCallView}. Because a completed UI update replaces the
* pending card content, mutation tools return this even when it repeats the
* call-time diff; otherwise raw result text would replace the diff.
*/
export interface DiffResultView {
card: 'diff'
+6 -51
View File
@@ -1,23 +1,4 @@
/**
* Typed tool-parameter schema DSL.
*
* Plugin authors write per-property specs with `required: true` as a boolean
* (the `SchemaSpec` type). A type-level helper (`InferArgs`) maps a SchemaSpec
* to the TS argument type. At runtime, `schemaSpecToJsonSchema()` converts a
* SchemaSpec to standard JSON Schema (`type: 'object'`, `properties`,
* `required` array) for the wire format sent to the model.
*
* # Why a custom DSL and not schemastery?
*
* Schemastery is a validation/transformation library (StandardSchema v1) used
* for plugin Config. Tool parameters need JSON Schema specifically (the LLM
* wire format), not validation. A lightweight DSL focused on JSON Schema
* generation, with type inference for the tool's `execute` args, gives plugin
* authors the best DX with the smallest surface area. Schemastery would add
* unnecessary indirection and wouldn't cleanly produce JSON Schema.
*
* @module dsh-tools/schema
*/
/** Typed tool-parameter DSL with argument inference and JSON Schema output. @module dsh-tools/schema */
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts'
@@ -328,39 +309,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
}
/**
* Define a tool with a typed parameter schema.
*
* Use this instead of constructing a raw {@link ToolDefinition} for all
* first-party tools. The `parameters` use the boolean-required style
* (`required: true` as a per-property flag), and `execute` receives typed
* args derived from the schema.
*
* ```ts
* const tool = defineTool({
* name: 'read_file',
* description: 'Read a file from disk.',
* parameters: {
* path: { type: 'string', required: true, description: 'Absolute file path' },
* offset: { type: 'number' },
* limit: { type: 'number', description: 'Max lines to read' },
* },
* async execute(args) {
* // args: { path: string; offset?: number; limit?: number }
* },
* })
* ```
*
* Raw JSON-Schema tool definitions (from MCP servers) are still accepted
* by `ToolRegistry.register()` directly — `defineTool` is sugar for
* first-party plugin authors.
*
* Define a first-party tool whose execution and presentation arguments are
* inferred from its per-property schema. Raw JSON-Schema definitions remain
* valid inputs to {@link ToolRegistry.register}; this helper is authoring sugar.
* @param options - the tool's name, description, typed parameter schema,
* execute body, and optional presenters.
* @returns a registry-ready {@link ToolDefinition}: its `execute` validates the
* raw args first (throwing {@link ToolArgsError} on mismatch, which the
* registry turns into an isError result), and its presenters validate softly
* (returning undefined on mismatch, since replay may feed them older-schema
* args).
* @returns a registry-ready definition with strict execution validation and
* soft presenter validation for replay compatibility.
*/
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
// Object-literal execute methods don't use `this`; the reference is safe.
+6 -17
View File
@@ -1,17 +1,8 @@
/**
* Code Mode codegen: the pure projection from registered tool schemas to the
* TypeScript SDK text the model programs against (the `tools:sdk` prompt
* section). Sibling of `json-schema.ts` — `schemas()` (native function
* calling) and this module (the generated `declare const tools` surface) are
* two projections of the same store.
*
* TOTAL by design: {@link jsonSchemaToTs} maps the JSON-Schema subset the
* `defineTool` DSL emits and degrades every construct outside it (`$ref`,
* `oneOf`, `integer`, future MCP shapes, …) to `unknown` without ever
* throwing — codegen must never be the thing that fails an assembly.
* Deterministic: a fixed tool set renders byte-identical text (tools in
* lexicographic name order), so the section is prefix-cache-friendly.
*
* Code Mode codegen: the pure projection from registered tool schemas to the TypeScript SDK
* text the model programs against (the `tools:sdk` prompt section). Sibling of
* `json-schema.ts` — `schemas()` (native function calling) and this module (the generated
* `declare const tools` surface) are two projections of the same store.
* @module @deepseek-ai/dsh-tools/src/ts-types
*/
@@ -33,10 +24,8 @@ function pad(indent: number): string {
/** A one-line JSDoc block for a schema `description`, or no lines when there is none. */
function docLines(description: unknown, indent: number): string[] {
if (typeof description !== 'string' || description.length === 0) return []
// Keep the doc a single-line comment per property: descriptions are prose
// (possibly with newlines); collapse whitespace so the rendered SDK stays
// stable and compact. A comment-closer inside the description is escaped so
// it cannot terminate the generated JSDoc early.
// Collapse prose to stable one-line docs and escape comment closers so a
// schema description cannot terminate generated JSDoc.
const collapsed = description.replace(/\s+/g, ' ').trim()
return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`]
}