docs: trim generated prose
This commit is contained in:
@@ -1,15 +1,5 @@
|
||||
/**
|
||||
* 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: the `run_code` tool and its dispatch bridge.
|
||||
* @module @deepseek-ai/dsh-tools/src/code-mode
|
||||
*/
|
||||
|
||||
@@ -24,16 +14,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 +74,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) {
|
||||
@@ -172,11 +152,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 +189,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 +242,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 +263,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,
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
/**
|
||||
* 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 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.
|
||||
* Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
|
||||
* @module @deepseek-ai/dsh-tools
|
||||
*/
|
||||
|
||||
@@ -83,82 +75,39 @@ 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.
|
||||
* The returned union is validated as an exact runtime shape before approval
|
||||
* or guards run; a malformed JavaScript/casted decision fails closed as an
|
||||
* `isError` result and the tool body never runs.
|
||||
* 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).
|
||||
* Waterfall before a tool runs — the gate where sandbox, permission, and hook plugins
|
||||
* allow or deny a call (Claude Code's `PreToolUse`).
|
||||
*
|
||||
* @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 capability or scope was
|
||||
* authorized. (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 wrapping the registry's core tool dispatch, between the
|
||||
* `tools/pre-execute` gate and the `tools/post-execute` seam.
|
||||
*
|
||||
* Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
|
||||
* @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).
|
||||
* 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`).
|
||||
*
|
||||
* Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
|
||||
* @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: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
/**
|
||||
* Awaited 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.
|
||||
* Awaited notification of the authoritative final tool outcome, after the complete
|
||||
* pre/execute/post pipeline, final lossless-JSON validation, and outer error
|
||||
* normalization.
|
||||
*
|
||||
* Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
|
||||
* @param exec - the execution object that traversed the pipeline.
|
||||
* @param result - a deep-frozen snapshot of the final returned result.
|
||||
* @mode parallel
|
||||
@@ -335,38 +284,14 @@ export interface ToolExecutionResult {
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 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-execution decision: dispatch, deny with a reason, or ask the approval seam. */
|
||||
// TODO(pre-tool-input-rewrite): design logged argument rewriting before exposing it here.
|
||||
export type PreToolDecision =
|
||||
| { kind: 'allow' }
|
||||
| { kind: 'deny'; reason: string }
|
||||
| { 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-execution decision: accept optional replacement content or block with feedback. */
|
||||
export type PostToolDecision =
|
||||
| { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext }
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
|
||||
@@ -408,32 +333,16 @@ 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.
|
||||
* The presentation mode. `'native'` (the default) contributes every visible end capability
|
||||
* as a native wire function definition.
|
||||
*/
|
||||
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 an explicit grant that bypasses them (which is what keeps
|
||||
* e.g. a structured-output capture tool alive under an allow-list). 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.
|
||||
* 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.
|
||||
*/
|
||||
export interface ToolRestriction {
|
||||
/** Global tool names that stay visible; everything else is removed. */
|
||||
@@ -458,25 +367,9 @@ 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 visibility function ({@link visible}) feeds prompt assembly,
|
||||
* {@link get}, and {@link execute} — and, under a non-native mode, the SDK
|
||||
* section and `run_code`'s bindings — so what the model is shown, what a
|
||||
* presenter renders, what a program can call, and what dispatches can never
|
||||
* disagree.
|
||||
* 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.
|
||||
*/
|
||||
export class ToolRegistry extends Service {
|
||||
static inject = ['systemPrompt']
|
||||
@@ -501,11 +394,7 @@ export class ToolRegistry extends Service {
|
||||
// The schema already defaulted an omitted mode; the ?? narrows the
|
||||
// optional-input type for direct (non-Loader) construction in tests.
|
||||
this.mode = config.mode ?? 'native'
|
||||
// `run_code` is presentation infrastructure, not an end capability. It
|
||||
// therefore does not enter the global layer: per-agent restrictions must
|
||||
// not remove it, and a scoped registration must not shadow it. The
|
||||
// visibility resolver appends this reserved definition after resolving
|
||||
// the filterable global/scoped capability layers.
|
||||
// `run_code` is presentation infrastructure, not an end capability.
|
||||
this.codeTransport = this.mode === 'native'
|
||||
? undefined
|
||||
: deepFreeze(createRunCodeTool(this, () => this.requireCodeRuntime()))
|
||||
@@ -514,43 +403,20 @@ 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 the scoped tool SDK on every assembly in stable lexical order.
|
||||
text: (context) => {
|
||||
this.requireCodeRuntime()
|
||||
return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME))
|
||||
},
|
||||
})
|
||||
// These are presentation infrastructure, not optional end capabilities.
|
||||
// Protect them at their owner: assembly listeners may still transform
|
||||
// ordinary tools and prose, but cannot silently leave Code Mode without
|
||||
// its only wire transport or the SDK that tells the model how to use it.
|
||||
ctx.systemPrompt.protect({ sections: ['tools:sdk'], tools: [RUN_CODE_NAME] })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {@link knownNames} universe for `toolOrder` validation.
|
||||
* 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}).
|
||||
*/
|
||||
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
|
||||
if (this.mode === 'native') return { schemas: this.schemas(scope), knownNames: this.knownNames(scope) }
|
||||
@@ -582,20 +448,8 @@ 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. Registration validates and
|
||||
* clones the JSON parameters, copies scalar fields, binds each callback once
|
||||
* to the caller's definition as its method receiver, and freezes the stored
|
||||
* snapshot; later mutation or callback replacement on the input object does
|
||||
* not rewrite the registry. Disposed with the calling fiber. Emits
|
||||
* `tools/change` on register/unregister.
|
||||
* Register a tool.
|
||||
*
|
||||
* @param definition - the tool's schema plus its execute (and optional
|
||||
* presentation) functions.
|
||||
* @returns the disposer that unregisters the tool. The exact
|
||||
@@ -605,11 +459,6 @@ export class ToolRegistry extends Service {
|
||||
register(definition: ToolDefinition): () => Promise<void> | void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
// A schema crosses the same model/log boundary as execution arguments.
|
||||
// Validate BEFORE cloning because structuredClone silently turns some
|
||||
// forbidden values (for example class instances) into plain records, then
|
||||
// validate the detached value again to contain hostile getters that change
|
||||
// between inspection and snapshotting. A frozen Map is still mutable, so
|
||||
// deepFreeze alone is not a sufficient registration boundary.
|
||||
if (!isJsonValue(definition.parameters)) {
|
||||
throw new TypeError('tool parameters must be losslessly JSON-serializable')
|
||||
}
|
||||
@@ -643,11 +492,10 @@ export class ToolRegistry extends Service {
|
||||
: `tool "${snapshot.name}" is already registered in this scope`)
|
||||
}
|
||||
layer.set(snapshot.name, snapshot)
|
||||
// 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.
|
||||
// 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).
|
||||
yield () => {
|
||||
layer.delete(snapshot.name)
|
||||
// An emptied scope layer is dropped so a disposed scope leaves no
|
||||
@@ -657,31 +505,13 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
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). Fire-and-forget callers may still
|
||||
// discard the (always-resolved) promise.
|
||||
// Return the exact Cordis disposer so generator effects preserve teardown nesting.
|
||||
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 scope's CURRENT pre-restriction
|
||||
* name universe ({@link knownNames}) and throws on an unknown one (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 filter is SNAPSHOT at
|
||||
* registration: later caller mutation of the arrays changes nothing.
|
||||
* Multiple restrictions compose by intersection. Scoped registrations
|
||||
* bypass restrictions (explicit grants win). Disposed with the calling
|
||||
* fiber (revocable independently); emits `tools/change`.
|
||||
* Restrict the global tool surface for the calling scope.
|
||||
*
|
||||
* @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
|
||||
@@ -722,12 +552,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). Fire-and-forget callers may still
|
||||
// discard the (always-resolved) promise.
|
||||
// Return the exact Cordis disposer so generator effects preserve teardown nesting.
|
||||
return dispose
|
||||
}
|
||||
|
||||
@@ -856,14 +681,9 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing schemas of everything `scope` can see — exactly the
|
||||
* fields (`name`, `description`, `parameters`) sent to the model via the
|
||||
* system-prompt assembly. 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.
|
||||
* The model-facing schemas of everything `scope` can see — exactly the fields (`name`,
|
||||
* `description`, `parameters`) sent to the model via the system-prompt assembly.
|
||||
*
|
||||
* @param scope - the viewing scope (the agent); omitted = the global view.
|
||||
* @returns one deep-cloned schema per visible tool.
|
||||
*/
|
||||
@@ -895,26 +715,12 @@ 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 must survive
|
||||
* a lossless JSON round trip; an invalid outcome is normalized to an error.
|
||||
* A malformed runtime/casted `tools/pre-execute` decision likewise normalizes
|
||||
* to an error before approval, guards, or the tool body.
|
||||
* Caller-owned arguments must survive lossless-JSON validation before and
|
||||
* after cloning; a violation normalizes to an error before policy or dispatch.
|
||||
* 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.
|
||||
*
|
||||
* @param exec - the single-use call input; its identity is snapshotted and
|
||||
* protected before policy runs.
|
||||
* @returns the final result after every waterfall; failures resolve as
|
||||
@@ -925,11 +731,8 @@ export class ToolRegistry extends Service {
|
||||
try {
|
||||
execution = this.prepareExecution(exec)
|
||||
} catch (error: unknown) {
|
||||
// Contract-violating non-JSON or non-cloneable arguments cannot enter a
|
||||
// pipeline whose logged and executed forms must agree. Still publish one
|
||||
// scoped final outcome, using an immutable identity shell, so result
|
||||
// observers retain their every-call guarantee without seeing the invalid
|
||||
// value.
|
||||
// Contract-violating non-JSON or non-cloneable arguments cannot enter a pipeline whose
|
||||
// logged and executed forms must agree.
|
||||
execution = Object.freeze({
|
||||
token: createExecutionToken(),
|
||||
callId: exec.callId,
|
||||
@@ -945,11 +748,8 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
let result: ToolExecutionResult
|
||||
try {
|
||||
// Validate the authoritative FINAL result, not merely the tool body's
|
||||
// intermediate return. Post-policy may replace content or attach context,
|
||||
// and every one of these fields is session-bound. Reject anything that
|
||||
// cannot round-trip losslessly through the durable JSON log before the
|
||||
// observe-only `tools/result` commit point sees success.
|
||||
// Validate the authoritative final result, not merely the tool body's intermediate
|
||||
// return.
|
||||
result = this.snapshotExecutionResult(execution, await this.executePipeline(execution))
|
||||
} catch (error: unknown) {
|
||||
// Outer backstop: a throwing pre/post-execute listener, guard, or the
|
||||
@@ -1002,10 +802,7 @@ export class ToolRegistry extends Service {
|
||||
|
||||
/** Run the transformable pipeline; {@link execute} owns final normalization and notification. */
|
||||
private async executePipeline(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
// --- Gate: tools/pre-execute. An `ask` resolves through the optional
|
||||
// approval seam (or degrades to deny) before the monotonic guards run. The
|
||||
// carrier keys dispatch by exec.agent, so an `agent.ctx` listener gates only
|
||||
// its own agent's calls (agent-less calls are subject-less).
|
||||
// --- Gate: tools/pre-execute.
|
||||
const carrier = scopeTarget(this, exec.agent)
|
||||
const gate = this.snapshotPreDecision(await this.ctx.waterfall(
|
||||
carrier, 'tools/pre-execute', exec,
|
||||
@@ -1026,14 +823,7 @@ export class ToolRegistry extends Service {
|
||||
return await this.postExecute(exec, denied)
|
||||
}
|
||||
|
||||
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
|
||||
// with-normalization thunk — the tool body's own try/catch turns a throw
|
||||
// into an isError result so a wrapper (and post-execute) can inspect it;
|
||||
// an unknown tool routes through the same catch. A `tools/execute` listener
|
||||
// (e.g. a timeout plugin) wraps this thunk: it may replace `exec.signal`
|
||||
// before delegating and inspect the normalized result after. Dispatched with the
|
||||
// same carrier as the gate, so an `agent.ctx` wrapper wraps only its own
|
||||
// agent's calls. ---
|
||||
// --- Around-dispatch: tools/execute.
|
||||
const result = this.snapshotExecutionResult(exec, await this.ctx.waterfall(
|
||||
carrier, 'tools/execute', exec,
|
||||
async (): Promise<ToolExecutionResult> => {
|
||||
@@ -1117,15 +907,7 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `ask` decision to allow/deny through the approval seam. The
|
||||
* seam is consumed opportunistically with `ctx.get('approval')` — a
|
||||
* deployment that composes no ApprovalService keeps the historical degrade
|
||||
* to deny, and an unmount mid-session degrades the same way on the next ask.
|
||||
* An agent-less execution also degrades: without an agent there is no
|
||||
* session to audit to and no UI to route to. Otherwise the outcome maps
|
||||
* one-to-one — `allowed-once` proceeds; the three non-grants deny with
|
||||
* distinct reasons so the model can tell a human "no" from an absent
|
||||
* approval channel.
|
||||
* Resolve an `ask` decision to allow/deny through the approval seam.
|
||||
*/
|
||||
private async serviceAsk(
|
||||
exec: ToolExecution,
|
||||
@@ -1163,14 +945,7 @@ export class ToolRegistry extends Service {
|
||||
* Runs inside `execute`'s outer try/catch (a throwing listener → isError).
|
||||
*/
|
||||
private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
|
||||
// Snapshot the protected outcome BEFORE the waterfall. A listener receives
|
||||
// the same `result` reference, so a post-waterfall read of `result.callId`/
|
||||
// `.isError`/`.error` could carry a listener's mutation — violating the
|
||||
// authoritative-call-id requirement and the "preserve the dispatched
|
||||
// isError/error" contract. The decision is the ONLY sanctioned channel for a
|
||||
// listener to change the outcome (block, or accept-with-replacement); the
|
||||
// call id is always the authoritative `exec.callId`. Deep cloning protects
|
||||
// nested content, error, and meta data from in-place listener mutation.
|
||||
// Snapshot the protected outcome before the waterfall.
|
||||
const dispatched = this.snapshotExecutionResult(exec, result)
|
||||
const decision = structuredClone(await this.ctx.waterfall(
|
||||
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
|
||||
@@ -1214,10 +989,8 @@ export class ToolRegistry extends Service {
|
||||
...result.additionalContext !== undefined ? { additionalContext: result.additionalContext } : {},
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}
|
||||
// Validate BEFORE cloning: structuredClone turns some forbidden exotic or
|
||||
// class instances into plain objects, which would hide a lossy JSON
|
||||
// boundary violation. Validate the detached clone again to contain hostile
|
||||
// getters whose value changes between inspection and snapshotting.
|
||||
// Validate before cloning: structuredClone turns some forbidden exotic or class instances
|
||||
// into plain objects, which would hide a lossy JSON boundary violation.
|
||||
if (!isJsonValue(candidate)) {
|
||||
throw new TypeError('tools/execute must return a losslessly JSON-serializable ToolExecutionResult')
|
||||
}
|
||||
|
||||
@@ -1,31 +1,7 @@
|
||||
/**
|
||||
* 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: the vocabulary a caller uses to demand a
|
||||
* machine-readable result from a subagent (`SubagentStartRequest.outputSchema`) or a workflow
|
||||
* `agent()` call.
|
||||
* @module dsh-tools/json-schema
|
||||
*/
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -186,16 +173,8 @@ 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}.
|
||||
*/
|
||||
export interface DiffResultView {
|
||||
card: 'diff'
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -263,15 +247,10 @@ function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate model-generated `args` against a {@link SchemaSpec}, returning a
|
||||
* list of human-readable violation messages (empty = valid). Total — never
|
||||
* throws, regardless of how malformed `args` is.
|
||||
* Validate model-generated `args` against a {@link SchemaSpec}, returning a list of
|
||||
* human-readable violation messages (empty = valid). Total — never throws, regardless of how
|
||||
* malformed `args` is.
|
||||
*
|
||||
* Semantics mirror {@link schemaSpecToJsonSchema} exactly: the top level must
|
||||
* be a non-array object; required keys come only from `required: true`; extra
|
||||
* keys are allowed (no `additionalProperties: false`); `default` is not
|
||||
* applied; an `object`/`array` prop without `properties`/`items` only
|
||||
* type-checks; `enum` is membership (strings only).
|
||||
* @param spec - the declared parameter schema to validate against.
|
||||
* @param args - the model-generated arguments, however malformed.
|
||||
* @returns the violation messages in declaration order; empty means valid.
|
||||
@@ -330,29 +309,6 @@ 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.
|
||||
* @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
|
||||
@@ -378,10 +334,7 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
||||
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
||||
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
|
||||
// Validate the model-generated args before the typed body runs. On
|
||||
// mismatch we throw ToolArgsError; the registry turns it into an
|
||||
// isError result so the model can self-correct. After this guard, the
|
||||
// cast to InferArgs<S> reflects the validated shape.
|
||||
// Validate the model-generated args before the typed body runs.
|
||||
const violations = validateArgs(options.parameters, args)
|
||||
if (violations.length > 0) throw new ToolArgsError(violations)
|
||||
return userExecute(args as InferArgs<S>, exec)
|
||||
|
||||
@@ -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.
|
||||
// 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.
|
||||
const collapsed = description.replace(/\s+/g, ' ').trim()
|
||||
return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`]
|
||||
}
|
||||
|
||||
@@ -358,10 +358,8 @@ describe('the run_code dispatch bridge', () => {
|
||||
return { logs: [], value: 'done' }
|
||||
}
|
||||
|
||||
// Model a timeout-style outer wrapper: it temporarily installs a signal,
|
||||
// delegates, then restores the exact prior shape. A nested result observer
|
||||
// is observe-only and must not receive the live outer execution object;
|
||||
// freezing the correlation value it sees therefore cannot break restore.
|
||||
// Model a timeout-style outer wrapper: it temporarily installs a signal, delegates, then
|
||||
// restores the exact prior shape.
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
if (exec.name !== RUN_CODE_NAME) return next()
|
||||
const previous = exec.signal
|
||||
@@ -585,11 +583,8 @@ describe('the run_code dispatch bridge', () => {
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
// Start a sub-dispatch, keep its rejection held, and fail the run once
|
||||
// the tool is genuinely in flight — a seam error AFTER work has begun.
|
||||
// The bridge's settlement still owes quiescence: without the finally,
|
||||
// run_code would return now and the slow tool would finish (and log)
|
||||
// afterwards.
|
||||
// Start a sub-dispatch, keep its rejection held, and fail the run once the tool is
|
||||
// genuinely in flight — a seam error after work has begun.
|
||||
request.bindings[0]!.functions.slow!({ id: 'orphan' }).catch(() => 'held')
|
||||
await inFlight
|
||||
throw new Error('backend exploded')
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
/**
|
||||
* Guarantee tests for the tool-schema catalog generator
|
||||
* (`scripts/gen-tool-catalog.ts`).
|
||||
*
|
||||
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
|
||||
* the freshness half is exercised by `pnpm run verify-tool-catalog` in CI. What
|
||||
* a freshness diff CANNOT prove is (a) that BOOTING the tool plugins yields the
|
||||
* shipped schema — the whole reason this generator boots instead of parsing
|
||||
* source (a runtime-spread enum resolves to its literal members) — and (b) that
|
||||
* the completeness guard REJECTS a tool package missing from the boot manifest,
|
||||
* the property that replaces the AST pass's "nothing silently omitted". These
|
||||
* tests drive the exported `collectToolCatalog` / `assertManifestComplete` /
|
||||
* `render` directly, mirroring the negative-path style of the cordis-catalog
|
||||
* generator tests.
|
||||
* Guarantee tests for the tool-schema catalog generator (`scripts/gen-tool-catalog.ts`).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -62,11 +50,8 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
})
|
||||
|
||||
it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => {
|
||||
// `tool-subagent`'s registered name is the load-time `toolName` config, so
|
||||
// the shipped agents surface this one package as both `subagent` and
|
||||
// `subagent_fork`. Booting yields only the default name; the note is how a
|
||||
// reader learns the fork alias the model also sees. Without it the catalog
|
||||
// would silently under-report the shipped tool surface.
|
||||
// `tool-subagent`'s registered name is the load-time `toolName` config, so the shipped
|
||||
// agents surface this one package as both `subagent` and `subagent_fork`.
|
||||
const catalog = await collectToolCatalog()
|
||||
const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent')
|
||||
expect(subagent?.schemas.map(s => s.name)).toEqual(['subagent'])
|
||||
|
||||
@@ -485,11 +485,7 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
|
||||
it('a post-execute listener cannot mutate any nested part of the dispatched result', async () => {
|
||||
// The decision is the ONLY sanctioned channel to change the outcome. A
|
||||
// listener that reaches in and mutates the passed result reference (flipping
|
||||
// isError, rewriting callId, attaching a bogus error) must NOT affect what
|
||||
// execute() returns — the registry snapshots the authoritative fields before
|
||||
// the waterfall and rebuilds from the snapshot + decision.
|
||||
// The decision is the only sanctioned channel to change the outcome.
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async (_exec, next) => {
|
||||
@@ -972,16 +968,9 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
|
||||
it('register() returns the EXACT effect disposer: a composite yield nests the teardown in order', async () => {
|
||||
// The registry-disposer convention (set by agents.register): the returned
|
||||
// function IS the cordis effect disposer, so a composite (generator)
|
||||
// effect that yields it has the unregistration run at that yield's LIFO
|
||||
// position on owner unload. A wrapper would leave the inner effect
|
||||
// disposing as a CONCURRENT SIBLING of the composite; the async probe
|
||||
// below (disposed first, LIFO) yields the event loop exactly like the
|
||||
// agent factory's stop-and-drain link, and a sibling unregistration fires
|
||||
// in that window — the probe would observe the tool already gone. Pins
|
||||
// the convention for the whole register-method family (system-prompt
|
||||
// registrars, registerProvider, setFactory share the same return).
|
||||
// The registry-disposer convention (set by agents.register): the returned function IS the
|
||||
// cordis effect disposer, so a composite (generator) effect that yields it has the
|
||||
// unregistration run at that yield's LIFO position on owner unload.
|
||||
const ctx = await setup()
|
||||
const order: string[] = []
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
@@ -1681,10 +1670,9 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
|
||||
presentCall: args => ({ card: 'generic', title: args.path }),
|
||||
presentResult: (args, result) => ({ card: 'generic', title: args.path, content: result.content }),
|
||||
})
|
||||
// Unlike execute (which throws ToolArgsError on a mismatch), the display
|
||||
// methods soft-validate and fall back to undefined so a UI never crashes
|
||||
// replaying an old/foreign log entry. The ToolDefinition methods take
|
||||
// `unknown`, so malformed shapes pass without a cast.
|
||||
// Unlike execute (which throws ToolArgsError on a mismatch), the display methods
|
||||
// soft-validate and fall back to undefined so a UI never crashes replaying an old/foreign
|
||||
// log entry.
|
||||
expect(tool.presentCall?.({})).toBeUndefined()
|
||||
expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user