mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
# Conflicts: # .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml # .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml
380 lines
17 KiB
TypeScript
380 lines
17 KiB
TypeScript
/**
|
||
* 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
|
||
*/
|
||
|
||
import { parse } from 'node:path'
|
||
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||
import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||
import { defineTool } from './schema.ts'
|
||
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. Before
|
||
* bounding, occurrences of a non-root session workspace path are
|
||
* normalized to `.` so host-specific absolute path lengths cannot change
|
||
* the summary.
|
||
* 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.
|
||
*/
|
||
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
|
||
}
|
||
}
|
||
|
||
/** The model-facing name of the Code Mode tool. */
|
||
export const RUN_CODE_NAME = 'run_code'
|
||
|
||
/** The `tools:sdk` section order: inside the 100–199 tool-guidance band, after per-tool guidance sections. */
|
||
export const SDK_SECTION_ORDER = 150
|
||
|
||
/**
|
||
* Thrown by `run_code` when the program run itself failed — a program
|
||
* exception, a budget expiry, an abort, or substrate death. Extends
|
||
* {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution
|
||
* pipeline converts it into a structured `isError` result whose text carries
|
||
* the failure kind plus the captured logs, so the model can self-correct.
|
||
*/
|
||
export class CodeRunFailedError extends HarnessError {
|
||
constructor(message: string) {
|
||
super(message, 'CODE_RUN_FAILED')
|
||
this.name = 'CodeRunFailedError'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cap for a `tool/code-dispatch` event's `resultSummary`. A log-ergonomics
|
||
* constant, not config: the full result already flows to the program; the
|
||
* summary exists so log readers see what a sub-call returned at a glance.
|
||
*/
|
||
const SUMMARY_MAX_CHARS = 200
|
||
|
||
/** Join Native content for the bounded durable sub-dispatch summary; non-text blocks become diagnostic placeholders. */
|
||
function textOf(content: ContentBlock[]): string {
|
||
return content
|
||
.map((block) => {
|
||
switch (block.type) {
|
||
case 'text': return block.text
|
||
// ContentBlockMap is merge-extensible — future block kinds land here
|
||
// deliberately (no assertNever on merge-extensible unions).
|
||
default: return `[${block.type} content]`
|
||
}
|
||
})
|
||
.join('\n')
|
||
}
|
||
|
||
/** Normalize workspace paths, then bound a sub-call's model-facing text for its durable log summary. */
|
||
function summarize(text: string, cwd: string | undefined): string {
|
||
const stableText = cwd === undefined || cwd === parse(cwd).root
|
||
? text
|
||
: text.replaceAll(cwd, '.')
|
||
return stableText.length > SUMMARY_MAX_CHARS ? `${stableText.slice(0, SUMMARY_MAX_CHARS)}…` : stableText
|
||
}
|
||
|
||
/**
|
||
* Snapshot one binding call's argument as lossless JSON, then snapshot that
|
||
* detached value again so dispatch and logging stay independent without
|
||
* reintroducing structured-clone's platform-specific nesting limit.
|
||
*/
|
||
function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
|
||
let snapshot: JsonValue | undefined
|
||
try {
|
||
snapshot = snapshotJsonValue(value) as JsonValue | undefined
|
||
} catch (error: unknown) {
|
||
throw new Error(`tool arguments must be lossless JSON: ${error instanceof Error ? error.message : String(error)}`)
|
||
}
|
||
if (snapshot === undefined) {
|
||
throw new Error('tool arguments must be lossless JSON (call the tool with an arguments object, e.g. `{}`)')
|
||
}
|
||
const logged = snapshotJsonValue(snapshot)
|
||
/* v8 ignore next -- snapshot is already a detached lossless JSON value. */
|
||
if (logged === undefined) {
|
||
throw new Error('tool arguments could not be detached for durable logging')
|
||
}
|
||
return { dispatched: snapshot, logged }
|
||
}
|
||
|
||
/** Two-space JSON presentation, matching the existing shallow `run_code` text contract. */
|
||
const JSON_INDENT = ' '
|
||
|
||
/**
|
||
* ECMAScript caps `JSON.stringify`'s `space` string at ten characters. The
|
||
* renderer also caps TOTAL indentation there, compacting deeper subtrees, so
|
||
* formatted output remains linear in the canonical JSON size.
|
||
*/
|
||
const MAX_JSON_INDENT_CHARS = 10
|
||
|
||
/** A pending fragment in the iterative JSON presentation traversal. */
|
||
type JsonRenderTask =
|
||
| { kind: 'text'; text: string }
|
||
| { kind: 'value'; value: JsonValue; depth: number; compact: boolean }
|
||
|
||
/** Render one non-string JSON root without recursive traversal or unbounded indentation growth. */
|
||
function renderJsonValue(value: Exclude<JsonValue, string>): string {
|
||
const chunks: string[] = []
|
||
const tasks: JsonRenderTask[] = [{ kind: 'value', value, depth: 0, compact: false }]
|
||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||
if (task.kind === 'text') {
|
||
chunks.push(task.text)
|
||
continue
|
||
}
|
||
|
||
const current = task.value
|
||
if (current === null || typeof current === 'boolean' || typeof current === 'number') {
|
||
chunks.push(String(current))
|
||
continue
|
||
}
|
||
if (typeof current === 'string') {
|
||
chunks.push(JSON.stringify(current))
|
||
continue
|
||
}
|
||
|
||
const compact = task.compact || (task.depth + 1) * JSON_INDENT.length > MAX_JSON_INDENT_CHARS
|
||
const childDepth = task.depth + 1
|
||
if (Array.isArray(current)) {
|
||
chunks.push('[')
|
||
if (current.length === 0) {
|
||
chunks.push(']')
|
||
continue
|
||
}
|
||
tasks.push({ kind: 'text', text: compact ? ']' : `\n${JSON_INDENT.repeat(task.depth)}]` })
|
||
for (let index = current.length - 1; index >= 0; index--) {
|
||
const item = current[index]
|
||
/* v8 ignore next -- canonical JsonValue arrays are dense. */
|
||
if (item === undefined) throw new Error('cannot render a sparse JSON array')
|
||
tasks.push({ kind: 'value', value: item, depth: childDepth, compact })
|
||
tasks.push({
|
||
kind: 'text',
|
||
text: compact
|
||
? index === 0 ? '' : ','
|
||
: `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}`,
|
||
})
|
||
}
|
||
continue
|
||
}
|
||
|
||
const keys = Object.keys(current)
|
||
chunks.push('{')
|
||
if (keys.length === 0) {
|
||
chunks.push('}')
|
||
continue
|
||
}
|
||
tasks.push({ kind: 'text', text: compact ? '}' : `\n${JSON_INDENT.repeat(task.depth)}}` })
|
||
for (let index = keys.length - 1; index >= 0; index--) {
|
||
const key = keys[index]
|
||
/* v8 ignore next -- the loop is bounded by the captured key count. */
|
||
if (key === undefined) throw new Error('cannot render a missing JSON object key')
|
||
const item = current[key]
|
||
/* v8 ignore next -- canonical JsonValue records contain no undefined properties. */
|
||
if (item === undefined) throw new Error('cannot render an undefined JSON object property')
|
||
tasks.push({ kind: 'value', value: item, depth: childDepth, compact })
|
||
tasks.push({
|
||
kind: 'text',
|
||
text: compact
|
||
? `${index === 0 ? '' : ','}${JSON.stringify(key)}:`
|
||
: `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}${JSON.stringify(key)}: `,
|
||
})
|
||
}
|
||
}
|
||
return chunks.join('')
|
||
}
|
||
|
||
/** Render one present program completion value for the model-facing result text. */
|
||
function renderValue(value: JsonValue): string {
|
||
return typeof value === 'string' ? value : renderJsonValue(value)
|
||
}
|
||
|
||
/** Canonical value returned by the outer Code Mode transport. */
|
||
type RunCodeOutput = { logs: string[]; result?: JsonValue }
|
||
|
||
/**
|
||
* Build the `run_code` {@link ToolDefinition}: one required `code` parameter,
|
||
* 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`,
|
||
* bindings cover its registered tools).
|
||
* @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud
|
||
* misconfiguration error (shared with the registry's assembly-time checks).
|
||
* @returns the registry-ready definition.
|
||
*/
|
||
export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime): ToolDefinition {
|
||
return defineTool({
|
||
name: RUN_CODE_NAME,
|
||
description:
|
||
'Execute a TypeScript program against the available tools. Write the BODY of an '
|
||
+ 'async function (erasable syntax only; top-level `await` and `return` work) and '
|
||
+ 'call tools as `await tools.name(args)` per the declarations in the system prompt. '
|
||
+ 'Only what you print or return comes back — curate it.',
|
||
parameters: {
|
||
code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
|
||
},
|
||
output: {
|
||
schema: {
|
||
type: 'object',
|
||
additionalProperties: false,
|
||
properties: {
|
||
logs: { type: 'array', required: true, items: { type: 'string' } },
|
||
result: { type: 'json' },
|
||
},
|
||
},
|
||
render: (_args, value) => {
|
||
const rendered = value.result === undefined ? '' : renderValue(value.result)
|
||
const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0)
|
||
return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }]
|
||
},
|
||
},
|
||
async execute(args, exec): Promise<RunCodeOutput> {
|
||
const runtime = requireRuntime()
|
||
|
||
// The run-scoped abort: follows the outer signal in, and fires when the
|
||
// run settles for ANY reason, so an in-flight sub-dispatch is aborted
|
||
// (its executor kills on this signal) instead of orphaned, and
|
||
// queued-unstarted dispatches are abandoned.
|
||
const runController = new AbortController()
|
||
const onOuterAbort = (): void => { runController.abort(exec.signal.reason) }
|
||
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).
|
||
let queue: Promise<void> = Promise.resolve()
|
||
const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
|
||
const turn = queue.then(() => {
|
||
if (runController.signal.aborted) {
|
||
throw new Error(`run_code run is over (${String(runController.signal.reason)}); tool call abandoned`)
|
||
}
|
||
return task()
|
||
})
|
||
queue = turn.then(() => undefined, () => undefined)
|
||
return turn
|
||
}
|
||
|
||
// Read through a call, not a bare property: the abort state genuinely
|
||
// changes across awaits, and a direct `.aborted` re-check after one
|
||
// would be narrowed away by control flow analysis.
|
||
const runOver = (): boolean => runController.signal.aborted
|
||
|
||
const binding = (name: string): CodeBindingFunction => async (rawArgs: unknown): Promise<JsonValue> => {
|
||
if (runOver()) {
|
||
throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} not dispatched`)
|
||
}
|
||
const normalized = jsonNormalizeArgs(rawArgs)
|
||
const outcome = await enqueue(async () => {
|
||
const n = ++dispatches
|
||
const subCallId = CallId(`${String(exec.callId)}:code:${n}`)
|
||
const result = await registry.execute({
|
||
callId: subCallId,
|
||
name,
|
||
arguments: normalized.dispatched,
|
||
...exec.agent ? { agent: exec.agent } : {},
|
||
parent: exec.token,
|
||
signal: runController.signal,
|
||
})
|
||
for (const context of result.additionalContexts ?? []) {
|
||
exec.deferContext(context)
|
||
}
|
||
const text = textOf(result.content)
|
||
exec.agent?.session.append('tool/code-dispatch', {
|
||
parentCallId: exec.callId,
|
||
subCallId,
|
||
name,
|
||
// The SIBLING parse of the dispatched value: byte-identical JSON,
|
||
// but a separate object — a tool mutating its args cannot desync
|
||
// this record from what it actually received.
|
||
arguments: normalized.logged,
|
||
isError: result.isError,
|
||
resultSummary: summarize(text, exec.agent.session.header.cwd),
|
||
})
|
||
return result.isError
|
||
? { isError: true as const, message: result.error.message }
|
||
: { isError: false as const, value: result.value }
|
||
})
|
||
// A budget expiry or outer cancel that lands while this call was in
|
||
// flight already aborted the dispatch; stop the program now rather
|
||
// than hand it a result from a run that is over.
|
||
if (runOver()) {
|
||
throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} result discarded`)
|
||
}
|
||
// The worker turns a binding rejection into ToolCallError and adds
|
||
// only the binding name. Native content and internal error metadata
|
||
// stay outside the program-facing failure contract.
|
||
if (outcome.isError) throw new Error(outcome.message)
|
||
return outcome.value
|
||
}
|
||
|
||
// Null-prototype + defineProperty, mirroring the worker-side namespace
|
||
// build: a registered tool named `__proto__` must become an ordinary
|
||
// own key (a plain-object assignment would hit the prototype setter,
|
||
// silently dropping the binding), and the runtime host resolves
|
||
// binding names as own properties only.
|
||
const functions: Record<string, CodeBindingFunction> = Object.create(null) as Record<string, CodeBindingFunction>
|
||
// Enumerate the CALLING AGENT's visible set (scoped tools join,
|
||
// restricted globals vanish) — the same view the SDK section declared,
|
||
// so a program can bind exactly what its prompt promised; sub-dispatch
|
||
// re-resolves per call through the same view (exec.agent threads down).
|
||
for (const schema of registry.schemas(exec.agent)) {
|
||
if (schema.name === RUN_CODE_NAME) continue
|
||
Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(schema.name) })
|
||
}
|
||
|
||
try {
|
||
let result: CodeRunResult
|
||
try {
|
||
result = await runtime.run({
|
||
program: args.code,
|
||
bindings: [{
|
||
global: 'tools',
|
||
functions,
|
||
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
|
||
}],
|
||
signal: runController.signal,
|
||
})
|
||
} finally {
|
||
// 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
|
||
}
|
||
|
||
if (result.error) {
|
||
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : ''
|
||
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
|
||
}
|
||
return {
|
||
logs: result.logs,
|
||
...result.value !== undefined ? { result: result.value } : {},
|
||
}
|
||
} finally {
|
||
exec.signal.removeEventListener('abort', onOuterAbort)
|
||
}
|
||
},
|
||
// ACP execute cards use the program as their visible title.
|
||
presentCall: args => ({
|
||
card: 'generic',
|
||
title: args.code,
|
||
kind: 'execute',
|
||
rawInput: args.code,
|
||
}),
|
||
// Title omitted on the result: an update replaces only the fields it
|
||
// carries, so the pending card's program title persists through
|
||
// completion. The durable final content already includes logs plus the
|
||
// return value, failure, or post-policy spill preview.
|
||
presentResult: (_args, result) => ({ card: 'generic', content: result.content }),
|
||
})
|
||
}
|