mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into codex/pr48-repo-hardening-rfcs
# Conflicts: # docs/adr/README.md # docs/rfc/009-session-persistence-and-resumability.md # docs/rfc/README.md # docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md # docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md # examples/acp-agent/tests/acp.e2e.ts # packages/acp/README.md # packages/acp/src/index.ts # packages/acp/tests/stream-update.spec.ts # packages/agent-loop/src/loop.ts # packages/tools/src/index.ts
This commit is contained in:
@@ -50,9 +50,156 @@ declare module 'cordis' {
|
||||
// parallel execution — Claude Code partitions read-only tools; phase 1
|
||||
// executes sequentially).
|
||||
|
||||
/**
|
||||
* Category of a tool call, used by a UI to pick an icon / treatment. A neutral
|
||||
* vocabulary owned here (NOT an ACP type) so tools describe themselves without
|
||||
* depending on any client protocol; a UI bridge maps it to its own enum. The
|
||||
* member set mirrors the common ACP `ToolKind` values; `other` is the default.
|
||||
*/
|
||||
export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'
|
||||
|
||||
// FIXME(tool-presentation): the ToolCallPresentation / ToolResultPresentation /
|
||||
// ToolTerminal shapes need a rethink. They grew incrementally (title/kind/
|
||||
// rawInput, then a `content` block, then a `terminal` sub-shape carrying cwd/
|
||||
// output/exit) and the split of responsibility is now muddy: the call vs result
|
||||
// terminal fields overlap, the bridge has to reconcile a `content` block AND a
|
||||
// `terminal` block AND `rawInput` per call, and the "pending vs completed"
|
||||
// boundary doesn't cleanly map to how editors actually render (terminal card,
|
||||
// diff, generic card). Before more tools/UIs depend on this, redesign the type
|
||||
// so a tool declares its render INTENT once (e.g. a tagged union over card
|
||||
// kinds) rather than a bag of optional fields the bridge stitches together.
|
||||
// Pin the design in an RFC and migrate dsh-tool-bash + the ACP bridge together.
|
||||
|
||||
/**
|
||||
* 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. Provider-
|
||||
* neutral: a tool returns this from {@link ToolDefinition.presentCall} and a UI
|
||||
* plugin (e.g. the ACP bridge) maps it to its own wire shape. The tool owns its
|
||||
* own presentation — the UI must not special-case tool names.
|
||||
*/
|
||||
export interface ToolCallPresentation {
|
||||
/**
|
||||
* Human-readable, always-visible label describing what THIS call does (e.g.
|
||||
* the model-written one-line summary of a bash command). Keep it short — a UI
|
||||
* shows it as a card header / log line. Required: a presentation must have a
|
||||
* title (a UI falls back to the tool name only when `presentCall` is absent).
|
||||
*/
|
||||
title: string
|
||||
/** Category for icon/treatment; defaults to `other` when omitted. */
|
||||
kind?: ToolCallKind
|
||||
/**
|
||||
* The salient input to surface in a detail/expanded view — e.g. the bash
|
||||
* COMMAND itself (as a string), so the title can stay a readable summary
|
||||
* while the exact command is still visible. Omit to show nothing; a string is
|
||||
* rendered as-is, an object as pretty JSON. NOT the full raw args object
|
||||
* unless that is genuinely what a reader wants.
|
||||
*/
|
||||
rawInput?: unknown
|
||||
/**
|
||||
* UI-facing content to show on the PENDING call alongside the title/card —
|
||||
* harness {@link ContentBlock}s, in render order. A terminal tool uses this to
|
||||
* surface its human-readable `description` as a text block ABOVE the terminal
|
||||
* card (the card itself is requested via {@link terminal} and labelled by the
|
||||
* command in `title`), since the card has no description slot. Omit to show no
|
||||
* extra content. A UI maps these to its own content blocks and renders a
|
||||
* {@link terminal} block (if any) as a terminal card.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
/**
|
||||
* Ask a capable UI to render this call as a TERMINAL (a command running in a
|
||||
* working directory), not a generic tool card — set by a tool whose call IS a
|
||||
* shell command (e.g. `bash`). Provider-neutral; a UI bridge maps it to its
|
||||
* own terminal affordance and a UI that can't falls back to the normal card.
|
||||
* Pair with {@link ToolResultPresentation.terminal} for the output/exit.
|
||||
*/
|
||||
terminal?: ToolTerminal
|
||||
}
|
||||
|
||||
/**
|
||||
* A request to render a tool call as a terminal. The pending presentation
|
||||
* supplies the working directory; the result presentation (see
|
||||
* {@link ToolResultPresentation.terminal}) supplies the captured output and exit
|
||||
* status. Provider-neutral — no client-protocol types. A UI that supports
|
||||
* terminals shows a cwd-headed terminal card with the command, its output, and
|
||||
* an exit-status pill; a UI that does not ignores this and renders the ordinary
|
||||
* card/content.
|
||||
*/
|
||||
export interface ToolTerminal {
|
||||
/**
|
||||
* Working directory the command ran in, shown as the terminal header. An
|
||||
* ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge
|
||||
* against the session workspace (the pure tool presenter can't see the
|
||||
* session cwd). Omit entirely to let the bridge use the session workspace.
|
||||
*/
|
||||
cwd?: string
|
||||
/** Captured command output (stdout+stderr as the tool chooses to combine them). Result-state only. */
|
||||
output?: string
|
||||
/**
|
||||
* Process exit code, when the run ended by exiting (not a signal). Result-state
|
||||
* only; lets a capable UI show an exit-status pill on the terminal card. Omit
|
||||
* when the command was killed by a signal or the exit code is unknown.
|
||||
*/
|
||||
exitCode?: number
|
||||
/**
|
||||
* Signal name that killed the process (e.g. `SIGTERM`), when it died by signal
|
||||
* rather than exiting. Result-state only; mutually exclusive with `exitCode`.
|
||||
*/
|
||||
signal?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* How a tool wants the COMPLETED call shown — the *result* state, after
|
||||
* `execute` returns. Lets the tool reformat its result for a UI distinctly from
|
||||
* the model-facing text it returned from `execute` (e.g. wrap command output in
|
||||
* a fenced ```console block for monospace rendering, which the model-facing
|
||||
* result must NOT carry). All fields optional: a UI keeps the pending-state
|
||||
* title and renders the raw result content for anything left unset.
|
||||
*/
|
||||
export interface ToolResultPresentation {
|
||||
/** Replacement title for the completed call (e.g. append an exit status). Omit to keep the pending-state title. */
|
||||
title?: string
|
||||
/**
|
||||
* UI-facing result content (harness {@link ContentBlock}s), reformatted from
|
||||
* the model-facing result. Omit to let the UI render the raw result content.
|
||||
* Stays in harness vocabulary; the UI maps these to its own content blocks.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
/**
|
||||
* Terminal output/exit for a call the pending presentation marked as a
|
||||
* terminal (see {@link ToolCallPresentation.terminal}). A capable UI renders
|
||||
* `output` in the terminal card and shows the exit status; an incapable UI
|
||||
* uses `content` (the tool should supply a text fallback there too).
|
||||
*/
|
||||
terminal?: ToolTerminal
|
||||
}
|
||||
|
||||
/** A registered tool: its schema plus the execution function. */
|
||||
export interface ToolDefinition extends ToolSchema {
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]>
|
||||
/**
|
||||
* Optional: how to present the PENDING state of one call in a UI, derived
|
||||
* from the call's `args` (parsed arguments, `unknown` — the tool validates/
|
||||
* narrows its own input). Returning `undefined` (or omitting the method) tells
|
||||
* a UI to fall back to a generic presentation (title = tool name, raw args as
|
||||
* input). Pure and side-effect-free: a UI may call it during live streaming
|
||||
* AND a session-log replay, so it must depend only on `args`.
|
||||
*/
|
||||
presentCall?(args: unknown): ToolCallPresentation | undefined
|
||||
/**
|
||||
* Optional: how to present the COMPLETED state, given the same `args` and the
|
||||
* `result` (`execute`'s content + whether it errored). Returning `undefined`
|
||||
* (or omitting the method) tells a UI to keep the pending title and render the
|
||||
* raw result content. Pure and side-effect-free for the same replay reason.
|
||||
*/
|
||||
presentResult?(args: unknown, result: ToolResult): ToolResultPresentation | undefined
|
||||
}
|
||||
|
||||
/** The completed outcome handed to {@link ToolDefinition.presentResult}. */
|
||||
export interface ToolResult {
|
||||
/** The model-facing content `execute` returned (or the error text on failure). */
|
||||
content: ContentBlock[]
|
||||
/** Whether the call failed. */
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
/** One pending tool call, as it flows through the execution waterfall. */
|
||||
@@ -166,16 +313,21 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all registered tool schemas, stripped of their `execute` functions.
|
||||
* These are exactly what gets sent to the model via the system-prompt
|
||||
* assembly.
|
||||
* Return all registered tool schemas — exactly the model-facing fields
|
||||
* (`name`, `description`, `parameters`, and `strict` when set), as 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.
|
||||
*/
|
||||
schemas(): ToolSchema[] {
|
||||
// Rest-destructure to drop `execute`; the unused binding is the idiom.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method, @typescript-eslint/no-unused-vars
|
||||
return [...this.store.values()].map(({ execute, ...schema }) => ({
|
||||
...schema,
|
||||
parameters: structuredClone(schema.parameters),
|
||||
return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({
|
||||
name,
|
||||
description,
|
||||
parameters: structuredClone(parameters),
|
||||
...strict !== undefined ? { strict } : {},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -186,33 +338,33 @@ export class ToolRegistry extends Service {
|
||||
* an `isError` result so the loop never sees an uncaught exception; a thrown
|
||||
* {@link HarnessError} surfaces its `{ name, code }` on the result.
|
||||
*/
|
||||
execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
return this.ctx.waterfall(this, 'tools/execute', exec, async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
const tool = this.store.get(exec.name)
|
||||
// Unknown tool routes through the same catch as a tool-thrown error, so
|
||||
// both failure classes get structured `{ name, code }` from one path.
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
const content = await tool.execute(exec.arguments, exec)
|
||||
return { callId: exec.callId, content, isError: false }
|
||||
} catch (error: unknown) {
|
||||
const info = errorInfo(error)
|
||||
return {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
|
||||
isError: true,
|
||||
...info ? { error: info } : {},
|
||||
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
try {
|
||||
return await this.ctx.waterfall(this, 'tools/execute', exec, async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
const tool = this.store.get(exec.name)
|
||||
// Unknown tool routes through the same catch as a tool-thrown error, so
|
||||
// both failure classes get structured `{ name, code }` from one path.
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
const content = await tool.execute(exec.arguments, exec)
|
||||
return { callId: exec.callId, content, isError: false }
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
}
|
||||
}).catch((error: unknown): ToolExecutionResult => {
|
||||
const info = errorInfo(error)
|
||||
return {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
|
||||
isError: true,
|
||||
...info ? { error: info } : {},
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
|
||||
const info = errorInfo(error)
|
||||
return {
|
||||
callId,
|
||||
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
|
||||
isError: true,
|
||||
...info ? { error: info } : {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolDefinition, ToolExecution } from './index.ts'
|
||||
import type { ToolCallPresentation, ToolDefinition, ToolExecution, ToolResult, ToolResultPresentation } from './index.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchemaSpec — the author-facing per-property type
|
||||
@@ -287,6 +287,22 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
* casts needed.
|
||||
*/
|
||||
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ContentBlock[]>
|
||||
/**
|
||||
* Optional: how to present the PENDING state of one call in a UI (an editor
|
||||
* tool-call card, a CLI log line). `args` is the typed, schema-validated
|
||||
* argument shape — zero casts. Pure and side-effect-free: a UI may call it
|
||||
* during live streaming AND a session-log replay, so depend only on `args`.
|
||||
* The tool owns its presentation so a UI never special-cases tool names. See
|
||||
* {@link ToolCallPresentation}.
|
||||
*/
|
||||
presentCall?(args: InferArgs<S>): ToolCallPresentation | undefined
|
||||
/**
|
||||
* Optional: how to present the COMPLETED state, given the typed `args` and the
|
||||
* `result`. Use it to reformat result content for a UI distinctly from the
|
||||
* model-facing text (e.g. a fenced ```console block). Pure and side-effect-
|
||||
* free for the same replay reason. See {@link ToolResultPresentation}.
|
||||
*/
|
||||
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultPresentation | undefined
|
||||
/** Whether the tool requires structured output (default false). */
|
||||
strict?: boolean
|
||||
}
|
||||
@@ -322,7 +338,11 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
// Object-literal execute methods don't use `this`; the reference is safe.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userExecute = options.execute
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userPresentCall = options.presentCall
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userPresentResult = options.presentResult
|
||||
const tool: ToolDefinition = {
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
||||
@@ -337,4 +357,21 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
return userExecute(args as InferArgs<S>, exec)
|
||||
},
|
||||
}
|
||||
// Presentation is display-only and may run on REPLAY of arbitrary logged args
|
||||
// (possibly from an older schema), so it must never throw: validate softly and
|
||||
// fall back to `undefined` (a generic UI presentation) on any mismatch, rather
|
||||
// than the hard `ToolArgsError` the execute path raises.
|
||||
if (userPresentCall) {
|
||||
tool.presentCall = (args: unknown): ToolCallPresentation | undefined => {
|
||||
if (validateArgs(options.parameters, args).length > 0) return undefined
|
||||
return userPresentCall(args as InferArgs<S>)
|
||||
}
|
||||
}
|
||||
if (userPresentResult) {
|
||||
tool.presentResult = (args: unknown, result: ToolResult): ToolResultPresentation | undefined => {
|
||||
if (validateArgs(options.parameters, args).length > 0) return undefined
|
||||
return userPresentResult(args as InferArgs<S>, result)
|
||||
}
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user