feat: add canonical typed tool outputs

This commit is contained in:
Tianyi Cui
2026-07-21 03:08:35 +08:00
parent 8500974fd4
commit 66c36e7325
173 changed files with 3298 additions and 954 deletions

View File

@@ -49,6 +49,8 @@ The overlay is computed from the current `ToolExecution` and passed through the
Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`.
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The Native renderer preserves the text above, including exactly `started background task <id>`; programmatic consumers use the typed fields without parsing those strings. Executor stream caps remain acquisition limits on `BashRunResult` and carry their spill paths.
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
## UI presentation

View File

@@ -22,7 +22,7 @@ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home'
import { processOutcome } from './background.ts'
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
@@ -311,6 +311,38 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
return modelWorkdir
}
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
function canonicalBashResult(result: BashRunResult) {
const output = (stream: BashRunResult['stdout']) => ({
text: stream.text,
truncated: stream.truncated,
...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {},
})
return {
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
aborted: result.aborted,
timeoutMs: result.timeoutMs,
stdout: output(result.stdout),
stderr: output(result.stderr),
...result.sandbox !== undefined ? {
sandbox: {
mode: result.sandbox.mode,
denied: result.sandbox.denied,
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
},
} : {},
}
}
/** Canonical background-handle properties shared by the bash output union. */
const BACKGROUND_OUTPUT_PROPERTIES = {
kind: { type: 'string', required: true, const: 'background' },
taskId: { type: 'string', required: true },
} as const
export function apply(ctx: Context, config: Config = {}): void {
const bashEnv = new BashEnvRegistry(ctx, config)
bashEnv.register({
@@ -398,6 +430,65 @@ export function apply(ctx: Context, config: Config = {}): void {
},
} : {},
},
output: {
schema: {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: BACKGROUND_OUTPUT_PROPERTIES,
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'foreground' },
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
timedOut: { type: 'boolean', required: true },
aborted: { type: 'boolean', required: true },
timeoutMs: { type: 'number', required: true },
stdout: {
type: 'object',
additionalProperties: false,
required: true,
properties: {
text: { type: 'string', required: true },
truncated: { type: 'boolean', required: true },
spillPath: { type: 'string' },
},
},
stderr: {
type: 'object',
additionalProperties: false,
required: true,
properties: {
text: { type: 'string', required: true },
truncated: { type: 'boolean', required: true },
spillPath: { type: 'string' },
},
},
sandbox: {
type: 'object',
additionalProperties: false,
properties: {
mode: { type: 'string', required: true },
denied: { type: 'boolean', required: true },
enforcement: { type: 'string' },
runnerFailed: { type: 'boolean' },
},
},
},
},
],
},
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? `started background task ${value.taskId}`
: renderResult(value as { kind: 'foreground' } & BashRunResult, escalationModes),
}],
},
async execute(args: BashToolArgs, exec) {
validateBashArgs(args)
// Description is display metadata; workdir defaults to the caller's session.
@@ -438,14 +529,14 @@ export function apply(ctx: Context, config: Config = {}): void {
}
},
})
return [{ type: 'text', text: `started background task ${id}` }]
return { kind: 'background' as const, taskId: id }
}
const result = await ctx.bash.run(ctx.bash.resolve({
...request,
...exec.signal ? { signal: exec.signal } : {},
}))
if (result.aborted) throw new Error('command aborted')
return [{ type: 'text', text: renderResult(result, escalationModes) }]
return { kind: 'foreground' as const, ...canonicalBashResult(result) }
},
presentCall: presentBashCall,
presentResult: presentBashResult,

View File

@@ -119,7 +119,13 @@ class RecordingSandboxExecutor extends BashExecutor {
timeoutMs: spec.timeoutMs,
stdout: { text: 'ok', truncated: false },
stderr: { text: '', truncated: false },
sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false },
sandbox: {
mode: spec.sandboxMode ?? 'read-only',
denied: false,
...spec.command === 'without optional sandbox facts'
? {}
: { enforcement: 'full' as const, runnerFailed: false },
},
})
}
@@ -204,6 +210,16 @@ describe('bash tool', () => {
const ctx = await setup()
const result = await call(ctx, 'bash', { command: 'echo hello', description: 'test command' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected bash success')
expect(result.value).toMatchObject({
kind: 'foreground',
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
stdout: { text: 'hello\n', truncated: false },
stderr: { text: '', truncated: false },
})
expect(text(result)).toBe('hello\n')
})
@@ -399,6 +415,8 @@ describe('background execution through the task runtime', () => {
const ctx = await setupWithTasks()
const started = await call(ctx, 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true })
expect(started.isError).toBe(false)
if (started.isError) throw new Error('expected background bash success')
expect(started.value).toEqual({ kind: 'background', taskId: 'bash-1' })
expect(text(started)).toBe('started background task bash-1')
const read = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, 'bg-ok')
@@ -606,6 +624,22 @@ describe('sandbox escalation through the generic task producer', () => {
expect(bash.modes).toEqual(['workspace-write', 'danger-full-access'])
})
it('omits sandbox facts the executor did not acquire from the canonical result', async () => {
const { ctx } = await setupSandboxed()
const result = await call(ctx, 'bash', {
command: 'without optional sandbox facts',
description: 'exercise optional sandbox facts',
})
if (result.isError) throw new Error('expected foreground bash success')
expect(result.value).toMatchObject({
kind: 'foreground',
sandbox: { mode: 'read-only', denied: false },
})
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement')
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed')
})
it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => {
const { ctx } = await setupSandboxed(true)
ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)

View File

@@ -4,7 +4,7 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -111,7 +111,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService, { contextWindow: 400 })
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'work',
description: 'does work',
parameters: { i: { type: 'number' } },

View File

@@ -151,7 +151,7 @@ describe('ToolResultPruneService session transaction', () => {
text: 'x'.repeat(100),
}], {
isError: true,
error: { name: 'ExitError', code: 'EXIT_1' },
error: { message: 'exit 1', info: { name: 'ExitError', code: 'EXIT_1' } },
meta: { diff: ['a', 'b'] },
futureField: { nested: true },
})
@@ -180,7 +180,7 @@ describe('ToolResultPruneService session transaction', () => {
step: 1,
callId: CallId('one'),
isError: true,
error: { name: 'ExitError', code: 'EXIT_1' },
error: { message: 'exit 1', info: { name: 'ExitError', code: 'EXIT_1' } },
meta: { diff: ['a', 'b'] },
futureField: { nested: true },
},

View File

@@ -6,7 +6,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as timeContext from '@deepseek-ai/dsh-time-context'
@@ -387,7 +387,7 @@ describe('real agent-loop request history', () => {
it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => {
const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')])
const ctx = await loopHarness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'tick',
description: 'advance fake time',
parameters: {},

View File

@@ -128,8 +128,7 @@ export function apply(ctx: Context, config: Config): void {
if (update === undefined) return downstream
pendingVersionUpdates.set(exec.token, update.versionUpdates)
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
...downstream,
additionalContexts: [update.context, ...downstream.additionalContexts ?? []],
}
})

View File

@@ -22,7 +22,7 @@ import type {
} from '@deepseek-ai/dsh-fs'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import {
@@ -800,6 +800,7 @@ describe('workspace context request injection', () => {
agent: stubAgent('/virtual/repo'),
}), {
isError: false,
value: null,
content: [{ type: 'text', text: 'file content' }],
}, async () => ({
kind: 'accept',
@@ -835,7 +836,8 @@ describe('workspace context request injection', () => {
agent,
})
const result = {
isError: false,
isError: false as const,
value: null,
content: [{ type: 'text' as const, text: 'hello' }],
}
@@ -1589,7 +1591,7 @@ describe('dynamic nested workspace context injection', () => {
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'abort_step',
description: 'Abort the current test step.',
parameters: {},
@@ -1660,6 +1662,7 @@ describe('dynamic nested workspace context injection', () => {
const pending = ctx.waterfall('tools/post-execute', exec, {
content: [{ type: 'text', text: 'ok' }],
isError: false,
value: null,
}, () => Promise.resolve({ kind: 'accept' as const }))
await expect(pending).rejects.toBe(reason)
@@ -2380,7 +2383,8 @@ describe('dynamic nested workspace context injection', () => {
const result = {
callId: CallId('provider-probe-result'),
content: [{ type: 'text' as const, text: 'ok' }],
isError: false,
isError: false as const,
value: null,
}
const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
@@ -2429,7 +2433,7 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('preserves nested and downstream post-execute contexts as separate entries', async () => {
it('preserves a downstream canonical value replacement and keeps contexts separate', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -2440,7 +2444,12 @@ describe('dynamic nested workspace context injection', () => {
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
content: [{ type: 'text' as const, text: 'downstream replacement' }],
value: {
path: 'pkg/deep/file.txt',
offset: 1,
lines: [{ number: 1, text: 'downstream replacement' }],
totalLines: 1,
},
additionalContexts: [{
content: [{ type: 'text' as const, text: 'downstream context' }],
source: { kind: 'plugin' as const, plugin: 'downstream' },
@@ -2454,7 +2463,15 @@ describe('dynamic nested workspace context injection', () => {
agent: stubAgent(root),
})
expect(blocksText(result.content)).toBe('downstream replacement')
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected read replacement success')
expect(result.value).toEqual({
path: 'pkg/deep/file.txt',
offset: 1,
lines: [{ number: 1, text: 'downstream replacement' }],
totalLines: 1,
})
expect(blocksText(result.content)).toContain('downstream replacement')
expect(result.additionalContexts).toHaveLength(2)
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.meta).toMatchObject({
@@ -2568,7 +2585,7 @@ describe('dynamic nested workspace context injection', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ToolFs)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'composite-read',
description: 'read through a nested dispatch',
parameters: {},
@@ -2620,7 +2637,7 @@ describe('dynamic nested workspace context injection', () => {
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
const agent = stubAgent('/')
const parent = Symbol('parent') as ToolExecutionToken
const plainResult = { callId: CallId('plain'), content: [], isError: false }
const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null }
ctx.emit('tools/result', stubToolExecution({
callId: CallId('agentless-child'), name: 'read', arguments: {}, parent,
@@ -2658,7 +2675,8 @@ describe('dynamic nested workspace context injection', () => {
const result = {
callId: CallId('manual'),
content: [{ type: 'text' as const, text: 'manual result' }],
isError: false,
isError: false as const,
value: null,
}
const cases = [
{ name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, agent: undefined },

View File

@@ -10,6 +10,8 @@ The self-referential cordis toolset: three model-facing tools over the live runt
Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md).
Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native renderers preserve the existing prose, so programs can use `mounted.id` while ordinary function calling still sees `mounted dyn-1 (...)`.
## Trust stance
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).

View File

@@ -1450,7 +1450,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventMap',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n /* …truncated — full shape in source */',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n message: string;\n info?: {\n name: string;\n code: string;\n };\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n content: Content /* …truncated — full shape in source */',
},
{
name: 'SessionEventReadRequest',
@@ -1674,20 +1674,20 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolDefinition',
declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
declaration: 'export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
},
{
name: 'ToolErrorInfo',
declaration: 'export interface ToolErrorInfo {\n name: string;\n code: string;\n}',
},
{
name: 'ToolExecuteReturn',
declaration: 'export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n};',
},
{
name: 'ToolExecution',
declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n}',
},
{
name: 'ToolExecutionFailure',
declaration: 'export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}',
},
{
name: 'ToolExecutionInput',
declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n}',
@@ -1698,16 +1698,28 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolExecutionResult',
declaration: 'export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n}',
declaration: 'export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;',
},
{
name: 'ToolExecutionSuccess',
declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}',
},
{
name: 'ToolExecutionToken',
declaration: 'export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n};',
},
{
name: 'ToolFailure',
declaration: 'export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n}',
},
{
name: 'ToolGuard',
declaration: 'export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;',
},
{
name: 'ToolOutputDefinition',
declaration: 'export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n}',
},
{
name: 'ToolProviderResult',
declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n}',
@@ -1718,7 +1730,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolResult',
declaration: 'export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n}',
declaration: 'export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n}',
},
{
name: 'ToolResultBlock',

View File

@@ -21,11 +21,11 @@ export const FiberState = {
export type FiberState = FiberStateEnum
/** Human-readable label for each {@link FiberState}, keyed by member (inlining-safe — no reverse mapping). */
export const STATE_LABELS: Record<FiberState, string> = {
export const STATE_LABELS = {
[FiberState.PENDING]: 'pending',
[FiberState.LOADING]: 'loading',
[FiberState.ACTIVE]: 'active',
[FiberState.FAILED]: 'failed',
[FiberState.DISPOSED]: 'disposed',
[FiberState.UNLOADING]: 'unloading',
}
} as const satisfies Record<FiberState, string>

View File

@@ -6,8 +6,8 @@
* return values with. The façade is a whitelist of lifecycle-safe verbs and declared services;
* framework internals and context-valued service returns are denied.
*
* VM-realm schemas are rebuilt as host objects, and tool results are JSON-round-tripped and
* shape-checked before session logging. Common JSON-Schema spellings are normalized when they
* VM-realm schemas and canonical values are rebuilt as host objects, while rendered content and
* presentation metadata are shape-checked before entering the registry. Common JSON-Schema spellings are normalized when they
* have one meaning; invalid vocabulary fails during registration with a teaching error.
* @module @deepseek-ai/dsh-tool-cordis/guard
*/
@@ -16,7 +16,9 @@ import { Context } from 'cordis'
import type { Plugin } from 'cordis'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool')
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json'])
@@ -254,34 +256,23 @@ const RETURN_PREVIEW_LIMIT = 120
* (`String(…)` for the un-stringifiable undefined case), truncated to
* {@link RETURN_PREVIEW_LIMIT}.
*/
function describeReturn(value: unknown): string {
// JSON.stringify is TYPED as always returning string, but it yields
// undefined for an undefined input (the routed forgot-return case) — the
// assertion widens the type back to the runtime truth.
const json = JSON.stringify(value) as string | undefined
if (json === undefined) return String(value)
function describeReturn(value: JsonValue): string {
// The caller has already crossed cloneJson, so this value is lossless JSON
// and serialization cannot produce undefined.
const json = JSON.stringify(value)
return json.length > RETURN_PREVIEW_LIMIT ? `${json.slice(0, RETURN_PREVIEW_LIMIT)}…` : json
}
/**
* Validate a round-tripped `execute` return against the two shapes
* {@link ToolExecuteReturn} allows: an ARRAY of content blocks, or
* `{ content: blocks, meta? }`. The registry trusts the shape blindly — it
* spreads `result.content`, so an unvalidated `{ content: 'ok' }` would enter
* the session log as `['o','k']` and silently corrupt the next model request —
* so a wrong shape fails THIS call with a teaching error instead.
* Validate and host-materialize a sandbox renderer's content blocks.
*/
function assertExecuteReturn(value: unknown): ToolExecuteReturn {
function assertRenderedContent(value: JsonValue): ContentBlock[] {
if (Array.isArray(value) && value.every(isContentBlockShape)) {
return value as ToolExecuteReturn
}
if (isPlainRecord(value) && Array.isArray(value.content) && value.content.every(isContentBlockShape)) {
return value as ToolExecuteReturn
return value as unknown as ContentBlock[]
}
throw new Error(
`execute returned ${describeReturn(value)} — a tool's execute must return an ARRAY of content blocks, never a bare string:\n`
+ ' ✓ return [{ type: \'text\', text: someString }]\n'
+ ' ✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }',
`output.render returned ${describeReturn(value)} — it must return an ARRAY of content blocks:\n`
+ ' ✓ return [{ type: \'text\', text: String(value) }]',
)
}
@@ -294,23 +285,46 @@ function assertExecuteReturn(value: unknown): ToolExecuteReturn {
* @param options - the standard `defineTool` options; `parameters` may be the ParameterSchemaSpec DSL or a JSON-Schema-style wrapper.
* @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
*/
export function sandboxDefineTool(options: Parameters<typeof defineTool>[0]): ToolDefinition {
const normalized = normalizeParameterSchemaSpec((options as { parameters?: unknown }).parameters)
const tool = defineTool({ ...options, parameters: normalized.spec } as Parameters<typeof defineTool>[0])
export function sandboxDefineTool(options: unknown): ToolDefinition {
if (!isPlainRecord(options)) throw new Error('harness.defineTool options must be an object')
const normalized = normalizeParameterSchemaSpec(options.parameters)
if (!isPlainRecord(options.output)) {
throw new Error('harness.defineTool output must declare { schema, render, presentationMeta? }')
}
const output = options.output
if (typeof output.render !== 'function') throw new Error('harness.defineTool output.render must be a function')
if (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function') {
throw new Error('harness.defineTool output.presentationMeta must be a function when present')
}
if (typeof options.execute !== 'function') throw new Error('harness.defineTool execute must be a function')
const schema = normalizeValueSchema(output.schema, 'output.schema')
const rawExecute = options.execute as (args: unknown, exec: unknown) => Promise<unknown>
const rawRender = output.render as (args: unknown, value: unknown) => unknown
const rawPresentationMeta = output.presentationMeta as ((args: unknown, value: unknown) => unknown) | undefined
const erasedDefineTool = defineTool as unknown as (definition: unknown) => ToolDefinition
const tool = erasedDefineTool({
...options,
parameters: normalized.spec,
output: {
schema,
render(args: unknown, value: unknown): ContentBlock[] {
return assertRenderedContent(cloneJson(rawRender(args, value), 'output.render result') as JsonValue)
},
...rawPresentationMeta !== undefined ? {
presentationMeta(args: unknown, value: unknown): JsonValue {
return cloneJson(rawPresentationMeta(args, value), 'output.presentationMeta result') as JsonValue
},
} : {},
},
async execute(args: unknown, exec: unknown): Promise<JsonValue> {
return cloneJson(await rawExecute(args, exec), 'execute result') as JsonValue
},
})
const parameters = { ...tool.parameters, ...normalized.rootAnnotations }
assertSupportedJsonSchema(parameters)
const execute = tool.execute.bind(tool)
return markDynamicTool({
...tool,
parameters,
async execute(args, exec) {
// JSON.stringify yields NO JSON for an undefined (or function/symbol)
// return despite its string-typed signature — route that into
// assertExecuteReturn's teaching error rather than letting JSON.parse
// throw its cryptic '"undefined" is not valid JSON'.
const json = JSON.stringify(await execute(args, exec)) as string | undefined
return assertExecuteReturn(json === undefined ? undefined : JSON.parse(json) as unknown)
},
})
}

View File

@@ -13,7 +13,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import { STATE_LABELS } from './fiber-state.ts'
import { isPlugin, pluginName } from './guard.ts'
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools } from './inspect.ts'
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools, providedServices } from './inspect.ts'
import { missingServices, mountDynamic, type DynamicMount } from './mount.ts'
import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts'
import { createSandbox, evaluateMountCode } from './sandbox.ts'
@@ -76,7 +76,11 @@ export function apply(ctx: Context, config: Config): void {
description: 'Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events".',
},
},
execute(args, exec): Promise<{ type: 'text'; text: string }[]> {
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
execute(args, exec): Promise<string> {
if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') {
throw new Error('name is valid only with what:"api" or what:"events"')
}
@@ -94,7 +98,7 @@ export function apply(ctx: Context, config: Config): void {
const text = selected
.map(([heading, body]) => `## ${heading}\n${body().join('\n')}`)
.join('\n\n')
return Promise.resolve([{ type: 'text', text }])
return Promise.resolve(text)
},
presentCall: presentInspectCall,
}))
@@ -119,13 +123,14 @@ export function apply(ctx: Context, config: Config): void {
+ 'Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe '
+ 'events (see cordis_inspect what:"events"), or call '
+ '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: '
+ '{ text: { type: \'string\', required: true } }, async execute(args) { … } }))` '
+ '{ text: { type: \'string\', required: true } }, output: { schema: { type: \'string\' }, '
+ 'render(_args, value) { return [{ type: \'text\', text: value }] } }, async execute(args) { return args.text } }))` '
+ 'to give yourself a new tool — it becomes callable on your NEXT step. '
+ 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'integer\'|\'boolean\'|\'null\'|\'object\'|\'array\'|\'json\', '
+ 'required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and '
+ 'oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: \'object\', properties, required?: […] } wrapper is also accepted with open-by-default objects. A '
+ 'tool\'s `execute` MUST return an ARRAY of content blocks, e.g. `return '
+ '[{ type: \'text\', text: someString }]` — never a bare string. '
+ 'tool\'s `execute` MUST return the lossless JSON value declared by `output.schema`; '
+ '`output.render(args, value)` separately returns Native/model content blocks. '
+ 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and '
+ 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending '
+ 'until the provider exists and returns to pending when the provider is unmounted. '
@@ -157,6 +162,32 @@ export function apply(ctx: Context, config: Config): void {
description: 'Body of an async JS function; must `return` the plugin to mount.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
pluginName: { type: 'string', required: true },
state: {
type: 'string',
required: true,
enum: ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'],
},
provides: { type: 'array', required: true, items: { type: 'string' } },
waitingFor: { type: 'array', required: true, items: { type: 'string' } },
},
},
render: (_args, value) => {
const note = value.waitingFor.length > 0
? ` — waiting for service(s): ${value.waitingFor.join(', ')} (activates when provided)`
: ''
return [{
type: 'text',
text: `mounted ${value.id} (plugin "${value.pluginName}", state: ${value.state}${note})`,
}]
},
},
async execute(args) {
const id = `dyn-${nextId++}`
const sandbox = createSandbox(id)
@@ -180,10 +211,13 @@ export function apply(ctx: Context, config: Config): void {
// it mounted but tell the model what it is waiting for.
const missing = missingServices(ctx, fiber)
const state = STATE_LABELS[fiber.state]
const note = missing.length > 0
? ` — waiting for service(s): ${missing.join(', ')} (activates when provided)`
: ''
return [{ type: 'text', text: `mounted ${id} (plugin "${pluginName(evaluated)}", state: ${state}${note})` }]
return {
id,
pluginName: pluginName(evaluated),
state,
provides: providedServices(ctx, fiber),
waitingFor: missing,
}
},
presentCall: presentMountCall,
}))
@@ -202,6 +236,17 @@ export function apply(ctx: Context, config: Config): void {
description: 'The dynamic mount id returned by cordis_mount (e.g. "dyn-1").',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
pluginName: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: `unmounted ${value.id} (plugin "${value.pluginName}")` }],
},
async execute(args) {
const mount = mounts.get(args.id)
if (!mount) {
@@ -209,7 +254,7 @@ export function apply(ctx: Context, config: Config): void {
}
await mount.fiber.dispose()
mounts.delete(args.id)
return [{ type: 'text', text: `unmounted ${args.id} (plugin "${mount.pluginName}")` }]
return { id: args.id, pluginName: mount.pluginName }
},
presentCall: presentUnmountCall,
}))

View File

@@ -33,8 +33,13 @@ function withinFiber(fiber: Fiber, root: Fiber): boolean {
}
}
/** The service names provided by a mount's fiber subtree, sorted. */
function providedBy(ctx: Context, fiber: Fiber): string[] {
/**
* Return the service names provided by a mount's fiber subtree.
* @param ctx - the runtime whose service registrations are inspected.
* @param fiber - the root of the mounted fiber subtree.
* @returns the provided service names in lexical order.
*/
export function providedServices(ctx: Context, fiber: Fiber): string[] {
return liveImpls(ctx)
.filter(impl => withinFiber(impl.fiber, fiber))
.map(impl => impl.name)
@@ -96,7 +101,7 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
export function describeDynamic(ctx: Context, mounts: ReadonlyMap<string, DynamicMount>): string[] {
if (mounts.size === 0) return ['(no dynamic plugins mounted)']
return [...mounts].map(([id, mount]) => {
const provides = providedBy(ctx, mount.fiber)
const provides = providedServices(ctx, mount.fiber)
const waiting = missingServices(ctx, mount.fiber)
const providesNote = provides.length > 0 ? ` — provides: ${provides.join(', ')}` : ''
const waitingNote = waiting.length > 0 ? ` — waiting for: ${waiting.join(', ')}` : ''

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { call, CONSUMER_CODE, PROVIDER_CODE, setup, text } from './helpers.ts'
import { call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, PROVIDER_CODE, setup, text } from './helpers.ts'
/**
* Cross-mount composition through ordinary cordis provide/inject semantics:
@@ -116,6 +116,7 @@ describe('cross-mount provide/inject', () => {
name: 'answer',
description: 'Read the provided primitive services.',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() {
return [{ type: 'text', text: ctx.answer + '/' + ctx.get('answer') + '/' + ctx.nothing }]
},

View File

@@ -45,6 +45,13 @@ export const LISTENER_CODE = `
}
`
/** Explicit content-array output declaration for dynamic-tool behavior fixtures. */
export const CONTENT_OUTPUT_CODE = `
output: {
schema: { type: 'array', items: { type: 'json' } },
render(_args, value) { return value },
},`
/** Mount code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */
export const REVERSE_TOOL_CODE = `
return {
@@ -55,8 +62,14 @@ export const REVERSE_TOOL_CODE = `
name: 'reverse_text',
description: 'Reverse a string.',
parameters: { text: { type: 'string', required: true } },
output: {
schema: { type: 'string' },
render(_args, value) {
return [{ type: 'text', text: value }]
},
},
async execute(args) {
return [{ type: 'text', text: args.text.split('').reverse().join('') }]
return args.text.split('').reverse().join('')
},
}))
},
@@ -83,8 +96,14 @@ export const CONSUMER_CODE = `
name: 'greet',
description: 'Greet someone via the greeter service.',
parameters: { name: { type: 'string', required: true } },
output: {
schema: { type: 'string' },
render(_args, value) {
return [{ type: 'text', text: value }]
},
},
async execute(args) {
return [{ type: 'text', text: ctx.greeter.greet(args.name) }]
return ctx.greeter.greet(args.name)
},
}))
},
@@ -97,8 +116,9 @@ export function dummyTool(name: string): ToolDefinition {
name,
description: 'test trigger',
parameters: { type: 'object' as const, properties: {} },
async execute(): Promise<[]> {
return []
output: { schema: { type: 'null' }, render: () => [] },
async execute(): Promise<null> {
return null
},
}
}

View File

@@ -16,6 +16,8 @@ describe('cordis_inspect', () => {
const result = await call(ctx, 'cordis_inspect', {})
expect(result.isError).toBe(false)
const report = text(result)
if (result.isError) throw new Error('expected cordis_inspect success')
expect(result.value).toBe(report)
for (const heading of ['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) {
expect(report).toContain(`## ${heading}`)
}

View File

@@ -1,7 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isJsonValue } from '@deepseek-ai/dsh-session'
import { sandboxDefineTool } from '../src/guard.ts'
import { syntaxErrorContext } from '../src/sandbox.ts'
import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
import { call, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
/**
* The `cordis_mount` success/failure family: real plugins land on a genuine
@@ -14,12 +15,48 @@ afterEach(() => {
})
describe('cordis_mount', () => {
it.each([
[42, 'options must be an object'],
[{ parameters: {} }, 'output must declare { schema, render, presentationMeta? }'],
[{ parameters: {}, output: { schema: { type: 'json' } }, execute: async (): Promise<null> => null }, 'output.render must be a function'],
[{ parameters: {}, output: { schema: { type: 'json' }, render: () => [] }, execute: true }, 'execute must be a function'],
[{
parameters: {},
output: { schema: { type: 'json' }, render: () => [], presentationMeta: true },
execute: async (): Promise<null> => null,
}, 'output.presentationMeta must be a function'],
])('rejects an invalid dynamic tool declaration before registration: %j', (definition, message) => {
expect(() => sandboxDefineTool(definition)).toThrow(message)
})
it('bounds the preview of an invalid dynamic renderer return', () => {
const definition = sandboxDefineTool({
name: 'invalid-renderer',
description: 'invalid renderer',
parameters: {},
output: {
schema: { type: 'string' },
render: () => ['x'.repeat(500)],
},
execute: async () => 'ok',
})
expect(() => definition.output.render({}, 'ok')).toThrow(/output\.render returned \["x+…/)
})
it('mounts a listener plugin that observes real events, tagged-logging through to the host console', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const result = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_mount success')
expect(result.value).toEqual({
id: 'dyn-1',
pluginName: 'change-logger',
state: 'active',
provides: [],
waitingFor: [],
})
expect(text(result)).toContain('mounted dyn-1 (plugin "change-logger", state: active)')
// Fire a REAL tools/change by registering a tool; the mounted listener logs.
@@ -44,6 +81,8 @@ describe('cordis_mount', () => {
expect(ctx.tools.schemas().map(schema => schema.name)).toContain('reverse_text')
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
expect(reversed.isError).toBe(false)
if (reversed.isError) throw new Error('expected dynamic tool success')
expect(reversed.value).toBe('ssenrah')
expect(text(reversed)).toBe('ssenrah')
})
@@ -55,7 +94,7 @@ describe('cordis_mount', () => {
expect(isJsonValue({ content: reversed.content, isError: reversed.isError })).toBe(true)
})
it('threads the { content, meta } object return form through to the registry result', async () => {
it('projects presentation metadata from a dynamic canonical value', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
@@ -67,8 +106,13 @@ describe('cordis_mount', () => {
name: 'meta_tool',
description: 'attaches a private presentation payload',
parameters: {},
output: {
schema: { type: 'string' },
render(_args, value) { return [{ type: 'text', text: value }] },
presentationMeta() { return { kind: 'demo' } },
},
async execute() {
return { content: [{ type: 'text', text: 'ok' }], meta: { kind: 'demo' } }
return 'ok'
},
}))
},
@@ -77,20 +121,20 @@ describe('cordis_mount', () => {
})
const result = await call(ctx, 'meta_tool', {})
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected dynamic tool success')
expect(result.value).toBe('ok')
expect(text(result)).toBe('ok')
expect(result.meta).toEqual({ kind: 'demo' })
})
it.each([
['a bare string', 'return \'ok\'', '"ok"'],
['an object whose content is a string', 'return { content: \'ok\' }', '{"content":"ok"}'],
['an array of non-objects', 'return [\'ok\']', '["ok"]'],
['blocks missing the type tag', 'return [{ text: \'hi\' }]', '[{"text":"hi"}]'],
['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', '{"content":[{"text":"hi"}]}'],
['undefined — a forgotten return', 'return undefined', 'undefined'],
])('rejects an execute return of %s as that one call\'s teaching error', async (_label, returnStatement, preview) => {
// The registry spreads result.content, so { content: 'ok' } would become ['o','k']; reject
// it as this call's error before it corrupts the next request.
['a bare string', 'return \'ok\'', 'returned invalid output: "value" must be an array'],
['an object whose content is a string', 'return { content: \'ok\' }', 'returned invalid output: "value" must be an array'],
['an array of non-objects', 'return [\'ok\']', 'output.render returned ["ok"]'],
['blocks missing the type tag', 'return [{ text: \'hi\' }]', 'output.render returned [{"text":"hi"}]'],
['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', 'returned invalid output: "value" must be an array'],
['undefined — a forgotten return', 'return undefined', 'execute result must be lossless JSON data'],
])('rejects an execute return of %s against its declared output', async (_label, returnStatement, diagnostic) => {
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
@@ -102,6 +146,7 @@ describe('cordis_mount', () => {
name: 'bad_return_tool',
description: 'returns a wrong shape',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() { ${returnStatement} },
}))
},
@@ -112,12 +157,10 @@ describe('cordis_mount', () => {
expect(result.isError).toBe(true)
expect(result.content).toHaveLength(1)
expect(result.content[0]!.type).toBe('text')
expect(text(result)).toContain(`execute returned ${preview}`)
expect(text(result)).toContain('must return an ARRAY of content blocks')
expect(text(result)).toContain('✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }')
expect(text(result)).toContain(diagnostic)
})
it('truncates a huge invalid execute return in the teaching error', async () => {
it('does not echo a huge schema-invalid canonical value in the diagnostic', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
@@ -129,6 +172,7 @@ describe('cordis_mount', () => {
name: 'huge_return_tool',
description: 'returns a huge wrong shape',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() { return 'x'.repeat(500) },
}))
},
@@ -137,7 +181,7 @@ describe('cordis_mount', () => {
})
const result = await call(ctx, 'huge_return_tool', {})
expect(result.isError).toBe(true)
expect(text(result)).toContain('…')
expect(text(result)).toContain('returned invalid output')
expect(text(result)).not.toContain('x'.repeat(200))
})
@@ -167,6 +211,7 @@ describe('cordis_mount', () => {
},
required: ['text'],
},
${CONTENT_OUTPUT_CODE}
async execute(args) { return [{ type: 'text', text: args.text + ':' + (args.count ?? 0) }] },
}))
},
@@ -215,6 +260,7 @@ describe('cordis_mount', () => {
cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] },
},
},
${CONTENT_OUTPUT_CODE}
async execute(args) { return [{ type: 'text', text: args.cfg.label }] },
}))
},
@@ -254,6 +300,7 @@ describe('cordis_mount', () => {
closed: { type: 'object', additionalProperties: false },
count: { type: 'number', enum: [1, 2], const: 1 },
},
${CONTENT_OUTPUT_CODE}
async execute(args) { return [{ type: 'text', text: String(args.choice) }] },
}))
},
@@ -299,6 +346,7 @@ describe('cordis_mount', () => {
choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] },
},
},
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
}))
},
@@ -356,6 +404,7 @@ describe('cordis_mount', () => {
name: 'bad_schema_tool',
description: 'bad',
${parameters},
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
}))
},
@@ -381,6 +430,7 @@ describe('cordis_mount', () => {
item: { type: 'object', additionalProperties: true, required: true, properties: { label: { type: 'string', required: true } } },
tags: { type: 'array', items: { type: 'string' } },
},
${CONTENT_OUTPUT_CODE}
async execute(args) { return [{ type: 'text', text: args.item.label }] },
}))
},
@@ -404,6 +454,7 @@ describe('cordis_mount', () => {
name: 'raw_dynamic_tool',
description: 'raw',
parameters: { type: 'object', properties: {} },
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
})
},
@@ -457,6 +508,14 @@ describe('cordis_mount', () => {
code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }',
})
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected pending cordis_mount success')
expect(result.value).toEqual({
id: 'dyn-1',
pluginName: 'waiter',
state: 'pending',
provides: [],
waitingFor: ['no-such-service'],
})
expect(text(result)).toContain('state: pending')
expect(text(result)).toContain('waiting for service(s): no-such-service')
// Unmounting a pending mount works like any other.
@@ -517,6 +576,7 @@ describe('cordis_mount', () => {
name: 'cordis_mount',
description: 'dup',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
}))
},
@@ -663,6 +723,7 @@ describe('cordis_mount', () => {
name: 'probe_instanceof',
description: 'report instanceof checks across realms',
parameters: { items: { type: 'array', required: true, items: { type: 'string' } } },
${CONTENT_OUTPUT_CODE}
async execute(args) {
const checks = {
hostArray: args.items instanceof Array,

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { call, setup, text } from './helpers.ts'
import { call, CONTENT_OUTPUT_CODE, setup, text } from './helpers.ts'
/**
* The sandbox context façade is a whitelist, not a pass-through proxy. Mounted code reaches only
@@ -51,6 +51,7 @@ describe('sandbox context façade — escape surface is closed', () => {
name: 'smuggled',
description: 'raw, unguarded',
parameters: { type: 'object', properties: {} },
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
})
},
@@ -87,6 +88,7 @@ describe('sandbox context façade — escape surface is closed', () => {
name: 'smuggled_via_service',
description: 'raw, unguarded',
parameters: { type: 'object', properties: {} },
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
})
},
@@ -116,6 +118,7 @@ describe('sandbox context façade — escape surface is closed', () => {
name: 'do_fetch',
description: 'awaits the host async service',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() {
const value = await ctx.hostAsync.grab()
return [{ type: 'text', text: value }]
@@ -203,6 +206,7 @@ describe('sandbox context façade — inject gate on services', () => {
name: 'greet_undeclared',
description: 'uses greeter without declaring it',
parameters: { n: { type: 'string', required: true } },
${CONTENT_OUTPUT_CODE}
async execute(args) { return [{ type: 'text', text: ctx.greeter.greet(args.n) }] },
}))
},
@@ -235,6 +239,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => {
name: 'report_view',
description: 'reports the shape of a tool view',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() {
const view = ctx.tools.get('cordis_mount')
return [{ type: 'text', text: JSON.stringify({
@@ -270,6 +275,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => {
name: 'probe_unknown',
description: 'reports whether an unknown tool resolves',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() {
return [{ type: 'text', text: String(ctx.tools.get('no_such_tool') === undefined) }]
},

View File

@@ -26,6 +26,8 @@ describe('cordis_unmount', () => {
const result = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_unmount success')
expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger' })
expect(text(result)).toContain('unmounted dyn-1')
// Immediately after the awaited unmount, the listener must be gone — no

View File

@@ -219,7 +219,10 @@ function appendSkippedToolCall(session: Session, turn: number, step: number, blo
appendToolResult(session, turn, step, block, {
content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: {
message: 'tool call skipped because the step was aborted before execution',
info: { name: 'AbortError', code: 'ABORTED' },
},
}, callSeq)
}

View File

@@ -6,7 +6,7 @@ import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
interface Harness {
@@ -156,7 +156,7 @@ describe('AgentLoop initiator scope', () => {
let parentWhileChildDriverActive: Agent | undefined
let child: Agent | undefined
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'spawn-child',
description: 'create one child agent',
parameters: {},
@@ -168,7 +168,7 @@ describe('AgentLoop initiator scope', () => {
setup: (agentCtx) => {
parentDuringSetup = ctx.agents.requireInitiator()
explicitChild = agentCtx.agent
agentCtx.tools.register(defineTool({
agentCtx.tools.register(defineContentToolFixture({
name: 'observe-child',
description: 'observe child execution identity',
parameters: {},
@@ -216,7 +216,7 @@ describe('AgentLoop initiator scope', () => {
let directAmbient: Agent | undefined
let captured: Agent | undefined
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'agentless-probe',
description: 'observe an agentless call',
parameters: {},
@@ -226,7 +226,7 @@ describe('AgentLoop initiator scope', () => {
return [{ type: 'text', text: 'ok' }]
},
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'capability-request',
description: 'call the test capability transport',
parameters: { path: { type: 'string' } },

View File

@@ -12,7 +12,7 @@ import { Context } from 'cordis'
import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -366,7 +366,7 @@ describe('Agent.cancel()', () => {
])
const ctx = await harness(adapter)
let executions = 0
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'danger',
description: 'must not run after cancellation',
parameters: {},
@@ -397,7 +397,7 @@ describe('Agent.cancel()', () => {
expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
callId: 'c1',
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { info: { name: 'AbortError', code: 'ABORTED' } },
})
send(agent, 'continue safely')

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
@@ -50,7 +50,7 @@ describe('session log records what agent/step-result actually produced', () => {
const adapter = new MockAdapter([original, textResponse('done')])
const ctx = await harness(adapter)
const executed: string[] = []
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'injected-tool',
description: '',
parameters: {},
@@ -219,7 +219,7 @@ describe('abort during tool execution ends the turn', () => {
const ctx = await harness(adapter)
const executed: string[] = []
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'aborter',
description: '',
parameters: {},
@@ -241,7 +241,7 @@ describe('abort during tool execution ends the turn', () => {
source: { kind: 'plugin', plugin: 'abort-test' },
}],
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'second',
description: '',
parameters: {},
@@ -259,7 +259,7 @@ describe('abort during tool execution ends the turn', () => {
case 'assistant/message': order.push('assistant/message'); break
case 'tool/call': order.push(`tool/call:${event.data.callId}`); break
case 'tool/result': {
const outcome = event.data.error?.code === 'ABORTED' ? 'synthetic-aborted' : 'real'
const outcome = event.data.error?.info?.code === 'ABORTED' ? 'synthetic-aborted' : 'real'
order.push(`tool/result:${event.data.callId}:${outcome}`)
break
}
@@ -308,7 +308,7 @@ describe('abort during tool execution ends the turn', () => {
expect(results[1]!.data).toMatchObject({
callId: CallId('c2'),
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { info: { name: 'AbortError', code: 'ABORTED' } },
})
})
@@ -316,7 +316,7 @@ describe('abort during tool execution ends the turn', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'aborter',
description: '',
parameters: {},
@@ -362,7 +362,7 @@ describe('abort during tool execution ends the turn', () => {
] satisfies StreamChunk[]])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'first',
description: '',
parameters: {},
@@ -370,7 +370,7 @@ describe('abort during tool execution ends the turn', () => {
return [{ type: 'text', text: 'first done' }]
},
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'aborter',
description: '',
parameters: {},
@@ -411,7 +411,7 @@ describe('abort during tool execution ends the turn', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'waiter',
description: '',
parameters: {},
@@ -463,7 +463,7 @@ describe('abort during tool execution ends the turn', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'aborter',
description: '',
parameters: {},
@@ -472,7 +472,7 @@ describe('abort during tool execution ends the turn', () => {
return [{ type: 'text', text: 'done' }]
},
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'second',
description: '',
parameters: {},
@@ -771,7 +771,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'noop',
description: '',
parameters: {},
@@ -837,7 +837,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const agent = ctx.agentLoop.create(SessionId('owned-steer'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'gate',
description: '',
parameters: {},
@@ -1423,7 +1423,7 @@ describe('tool result call identity', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo',
parameters: { x: { type: 'number' } },

View File

@@ -4,7 +4,7 @@ import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -77,7 +77,7 @@ describe('tool JSON parse', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo tool',
parameters: { input: { type: 'string' } },
@@ -110,7 +110,7 @@ describe('tool JSON parse', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'noarg',
description: 'no-arg tool',
parameters: {},
@@ -259,7 +259,7 @@ describe('structured tool error propagation (the runtime-validation Agent Note,
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'boom',
description: 'always fails',
parameters: {},
@@ -274,6 +274,6 @@ describe('structured tool error propagation (the runtime-validation Agent Note,
const toolResult = agent.session.events.find(e => e.type === 'tool/result')
expect(toolResult?.type === 'tool/result' && toolResult.data.isError).toBe(true)
expect(toolResult?.type === 'tool/result' && toolResult.data.error)
.toEqual({ name: 'HarnessError', code: 'BOOM' })
.toEqual({ message: 'exploded', info: { name: 'HarnessError', code: 'BOOM' } })
})
})

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -347,7 +347,7 @@ describe('agent/session-prefix', () => {
textResponse('again'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
@@ -469,7 +469,7 @@ describe('agent/session-prefix', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
@@ -522,7 +522,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
it('a stop decision ends the turn even when the step had tool calls', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' })])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
@@ -552,7 +552,7 @@ describe('tool additionalContexts buffering across a step', () => {
]
const adapter = new MockAdapter([twoCalls, textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
@@ -594,7 +594,7 @@ describe('tool additionalContexts buffering across a step', () => {
it('appends multiple contexts deferred by one composite tool after its outer result', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'composite', {}), textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'composite', description: 'composite', parameters: {},
async execute(_args, exec) {
exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } })
@@ -625,7 +625,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t
const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')])
const ctx = await harness(adapter)
let ran = false
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'danger', description: 'danger', parameters: {},
async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] },
}))
@@ -687,7 +687,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')])
const ctx = await harness(adapter)
await ctx.plugin(NativeGuard)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))

View File

@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, TurnEndReason, type JsonValue } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -89,7 +89,7 @@ describe('agent loop', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo back',
parameters: { text: { type: 'string' } },
@@ -118,22 +118,27 @@ describe('agent loop', () => {
const types = agent.session.events.map(e => e.type)
expect(types).toContain('tool/call')
expect(types).toContain('tool/result')
const durableResult = agent.session.events.find(event => event.type === 'tool/result')
expect(durableResult?.type === 'tool/result' && 'value' in durableResult.data).toBe(false)
})
it('threads a tool-attached meta (execute object return) onto the tool/result event', async () => {
it('persists presentation metadata projected from the canonical value', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'),
textResponse('done'),
])
const ctx = await harness(adapter)
// A tool that returns the { content, meta } object form: the loop must
// persist `meta` on the tool/result event so a UI reproduces the card on replay.
ctx.tools.register(defineTool({
name: 'writer',
description: 'writes a file',
parameters: { path: { type: 'string' } },
output: {
schema: { type: 'string' },
render: () => [{ type: 'text', text: 'ok' }],
presentationMeta: (_args, value) => ({ diffs: [{ path: value, oldText: null, newText: 'x' }] }),
},
async execute() {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } }
return 'a.txt'
},
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -152,7 +157,7 @@ describe('agent loop', () => {
// projecting this agent's configured model, so the model knows its own name.
const ctx = await harness(adapter, 'You are a test agent on {{model}}.')
ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'noop',
description: 'does nothing',
parameters: {},
@@ -248,7 +253,7 @@ describe('agent loop', () => {
['BigInt', { n: 1n }],
['Map', new Map([['key', 'value']])],
['class instance', new (class ResultMeta { x = 1 })()],
])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => {
])('normalizes non-JSON presentation metadata (%s) before the durable result commit', async (_kind, meta) => {
const adapter = new MockAdapter([
toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'),
textResponse('recovered'),
@@ -258,7 +263,12 @@ describe('agent loop', () => {
name: 'bad-meta',
description: 'returns invalid durable metadata',
parameters: {},
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
presentationMeta: () => meta as unknown as JsonValue,
},
execute: () => Promise.resolve('apparent success'),
}))
const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' })
@@ -326,7 +336,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'slow',
description: '',
parameters: {},
@@ -432,7 +442,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let visibleDuringTool = false
const meta = { kind: 'deferred-test', version: 1 }
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'noticer',
description: 'injects a notice',
parameters: {},
@@ -494,7 +504,7 @@ describe('agent loop', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'invalid-injector',
description: 'attempts an invalid context injection',
parameters: {},
@@ -541,7 +551,7 @@ describe('agent loop', () => {
it('agent/turn-continuation can veto continuation despite tool calls (budget-guard pattern)', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'x' })])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
@@ -589,7 +599,7 @@ describe('agent loop', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: {},
async execute() { return [{ type: 'text', text: 'echoed' }] },
}))
@@ -781,7 +791,7 @@ describe('agent loop', () => {
]])
const ctx = await harness(adapter)
let executions = 0
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
@@ -821,7 +831,7 @@ describe('agent loop', () => {
{ type: 'finish', reason: { kind: 'max-tokens' } },
]])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
@@ -908,7 +918,7 @@ describe('agent loop', () => {
textResponse('continued after tool call'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
@@ -1240,7 +1250,7 @@ describe('agent loop', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -45,7 +45,7 @@ async function loopHarness(): Promise<Context> {
await created.plugin(AgentRegistry)
await created.plugin(AgentLoop, { agents: [] })
await created.plugin(LlmDeepSeek)
created.tools.register(defineTool({
created.tools.register(defineContentToolFixture({
name: 'lookup',
description: 'Look up the stored value for a key.',
parameters: { key: { type: 'string', description: 'The key to look up.' } },

View File

@@ -11,7 +11,7 @@ import LlmService from '@deepseek-ai/dsh-llm'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -53,7 +53,7 @@ function expectPrefixExtension(previous: GenerateOptions, current: GenerateOptio
}
function registerEcho(ctx: Context) {
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo back',
parameters: { text: { type: 'string' } },

View File

@@ -11,7 +11,7 @@ import LlmService, {
import type { GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -133,7 +133,7 @@ describe('agent post-step and request-error lifecycle', () => {
]
const adapter = new FailureScriptAdapter([twoCalls, textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'work',
description: 'do work',
parameters: {},
@@ -222,7 +222,7 @@ describe('agent post-step and request-error lifecycle', () => {
textResponse('must not continue'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'work',
description: 'do work',
parameters: {},
@@ -535,7 +535,7 @@ describe('agent post-step and request-error lifecycle', () => {
contextError('later overflow'),
])
const resetCtx = await harness(reset)
resetCtx.tools.register(defineTool({
resetCtx.tools.register(defineContentToolFixture({
name: 'work',
description: 'continue',
parameters: {},

View File

@@ -3,7 +3,7 @@ import { Context, symbols, type EffectMeta, type Fiber } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -172,10 +172,10 @@ describe('agent scope lifecycle', () => {
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
const { agent } = handle
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
agent.ctx.tools.register({
agent.ctx.tools.register(defineContentToolFixture({
name: 'mine', description: 'scoped', parameters: {},
execute: () => Promise.resolve(text('ran')),
})
}))
const scopedAssembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
expect(scopedAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You run tests.')
@@ -587,12 +587,12 @@ describe('agent scope lifecycle', () => {
sessionId: SessionId('dependency-origin-s'),
agentOptions: { provider: 'mock', model: 'mock' },
setup: (agentCtx) => {
agentCtx.tools.register({
agentCtx.tools.register(defineContentToolFixture({
name: 'dependency-origin-tool',
description: 'proves AgentLoop dependency origin',
parameters: {},
execute: () => Promise.resolve(text('ok')),
})
}))
agentCtx.systemPrompt.section({
name: 'dependency-origin-section',
order: 1,

View File

@@ -9,7 +9,7 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import LlmService from '@deepseek-ai/dsh-llm'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -61,7 +61,7 @@ function multiCall(calls: { id: string; name: string; args: object }[]): StreamC
function gatedTool(name: string, parallel: boolean) {
const gates = new Map<string, () => void>()
const started: string[] = []
const tool = defineTool({
const tool = defineContentToolFixture({
name,
description: `gated ${name}`,
parameters: { id: { type: 'string', required: true } },
@@ -123,12 +123,12 @@ describe('tool-call scheduler: grouping and barriers', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'r', description: 'read', parameters: { id: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute(args) { order.push(`r-start-${args.id}`); order.push(`r-end-${args.id}`); return [{ type: 'text', text: 'r' }] },
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } },
async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] },
}))
@@ -150,14 +150,14 @@ describe('tool-call scheduler: grouping and barriers', () => {
])
const ctx = await harness(adapter)
const replacement = gatedExclusiveTool('x')
const disposeSafe = ctx.tools.register(defineTool({
const disposeSafe = ctx.tools.register(defineContentToolFixture({
name: 'x',
description: 'initially safe',
parameters: { id: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute(args) { return [{ type: 'text', text: `old-${args.id}` }] },
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'replace',
description: 'replace x',
parameters: { id: { type: 'string', required: true } },
@@ -476,8 +476,22 @@ describe('tool-call scheduler: abort handling', () => {
isError: e.data.isError,
error: e.data.error,
}))).toEqual([
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
{
callId: CallId('c1'),
isError: true,
error: {
message: 'tool call skipped because the step was aborted before execution',
info: { name: 'AbortError', code: 'ABORTED' },
},
},
{
callId: CallId('c2'),
isError: true,
error: {
message: 'tool call skipped because the step was aborted before execution',
info: { name: 'AbortError', code: 'ABORTED' },
},
},
])
})
@@ -509,7 +523,7 @@ describe('tool-call scheduler: abort handling', () => {
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2')])
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
.toMatchObject({ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
.toMatchObject({ callId: CallId('c2'), isError: true, error: { info: { name: 'AbortError', code: 'ABORTED' } } })
})
it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => {
@@ -538,10 +552,14 @@ describe('tool-call scheduler: abort handling', () => {
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data))
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => ({
callId: e.data.callId,
isError: e.data.isError,
errorInfo: e.data.error?.info,
})))
.toEqual([
expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }),
expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }),
{ callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: 'ABORTED' } },
{ callId: CallId('c4'), isError: true, errorInfo: { name: 'AbortError', code: 'ABORTED' } },
])
const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message')
expect(settled.map(e => e.type))
@@ -564,7 +582,7 @@ describe('tool-call scheduler: abort handling', () => {
const gated = gatedParallelTool('p')
const exclusive: string[] = []
ctx.tools.register(gated.tool)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'x',
description: 'exclusive',
parameters: { id: { type: 'string', required: true } },
@@ -583,6 +601,6 @@ describe('tool-call scheduler: abort handling', () => {
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2'), CallId('c3')])
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
.toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
.toMatchObject({ callId: CallId('c3'), isError: true, error: { info: { name: 'AbortError', code: 'ABORTED' } } })
})
})

View File

@@ -12,7 +12,7 @@ import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function registerNamed(ctx: Context, name: string) {
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name,
description: `the ${name} tool`,
parameters: {},

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -29,7 +29,7 @@ function send(agent: Agent, text = 'go'): Promise<void> {
}
function registerEcho(ctx: Context): void {
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo',
parameters: { text: { type: 'string' } },

View File

@@ -58,6 +58,8 @@ Durable values need one accepted representation, not a check followed by a secon
`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
`tool/result` persists the model-facing content, canonical failure detail, and optional presentation metadata. A tool's successful canonical `value` is deliberately execution-local and never enters the session event, so replay reconstructs the Native/model presentation but cannot recover intermediate programmatic values. This does not change `SESSION_FORMAT_VERSION`: the persisted projection remains authoritative.
### Session event vocabulary (`types.ts`)
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure.

View File

@@ -92,7 +92,10 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
callId,
content: [{ type: 'text', text: 'Tool call interrupted by a crash; no result was recorded.' }],
isError: true,
error: { name: 'InterruptedError', code: 'interrupted' },
error: {
message: 'Tool call interrupted by a crash; no result was recorded.',
info: { name: 'InterruptedError', code: 'interrupted' },
},
},
surfaceOp: 'append',
...callSeq !== undefined ? { sourceEventSeqs: [callSeq] } : {},

View File

@@ -243,15 +243,24 @@ export interface SessionEventMap {
*/
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
/**
* A completed tool call's model-facing result, plus an optional tool-private
* `meta` presentation payload. `meta` is opaque to the core (`unknown` — the
* producing tool owns its shape and reads it back in `presentResult`) but MUST
* be JSON-serializable: `Session.append` runtime-validates all event data with
* `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the
* durable log reproduces the identical card on replay. Absent unless the tool
* attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here).
* A completed tool call's model-facing result, canonical failure detail, and
* optional tool-private `meta` presentation payload. `meta` is opaque to the
* core (the producing tool owns its shape and reads it back in `presentResult`)
* but MUST be JSON-serializable: `Session.append` runtime-validates all event
* data with `isJsonValue`, so a non-serializable `meta` is rejected at the
* source, and the durable log reproduces the identical card on replay. Absent
* unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time
* contextual diff here).
*/
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown }
'tool/result': {
turn: number
step: number
callId: CallId
content: ContentBlock[]
isError: boolean
error?: { message: string; info?: { name: string; code: string } }
meta?: JsonValue
}
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */

View File

@@ -64,7 +64,7 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.seq)).toEqual([3, 4, 5])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data).toMatchObject({
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: 'interrupted' },
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { info: { code: 'interrupted' } },
})
})

View File

@@ -15,7 +15,7 @@ tools:
### Public API
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. Disposed with the calling fiber.
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing/unsupported output declarations and a non-positive/non-finite `timeoutMs` fail at registration. Disposed with the calling fiber.
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
@@ -33,14 +33,14 @@ The live registry pipeline has three transformable waterfalls followed by the ob
### Key types
- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification.
- `ToolDefinition` — `ToolSchema` + mandatory `output { schema, render, presentationMeta? }` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. A body returns only the canonical JSON value declared by the output schema.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute `HookContext` for the loop's post-result FIFO.
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
- `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
- `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
- `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
@@ -48,8 +48,8 @@ The live registry pipeline has three transformable waterfalls followed by the ob
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
- `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it.
- `tools/execute` wraps normalized core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal.
- `tools/post-execute` may replace content, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome.
- `tools/execute` wraps normalized canonical dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal; a wrapper-authored success is normalized through the resolved tool's output declaration.
- `tools/post-execute` may replace presentation content, replace the canonical value, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome. Content replacement is not a confidentiality boundary: block or replace the value when programmatic consumers must not receive it.
- Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md).
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
@@ -72,17 +72,20 @@ ctx.tools.register(defineTool({
offset: { type: 'number' },
limit: { type: 'number' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
// args is typed: { path: string; offset?: number; limit?: number }
const text = await readFile(args.path, 'utf8')
return [{ type: 'text', text }]
return readFile(args.path, 'utf8')
},
}))
```
The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default.
A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. Extra keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. Raw-registered tools own their validation.
A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. It also infers the body return and pure output projectors from `output.schema`; the registry snapshots and validates the returned lossless JSON before presentation. Extra parameter keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. Raw-registered tools own input validation but still declare and receive registry-enforced output.
See `defineTool`, `validateArgs`, `ToolArgsError`, `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, `InferArgs`, `valueSchemaSpecToJsonSchema`, and `parameterSchemaSpecToJsonSchema` in the public API for details.
@@ -101,7 +104,7 @@ Tools optionally own pure `presentCall()` and `presentResult()` render intents,
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`.
Returning `undefined` selects generic fallback. Presenters depend only on their arguments because UIs call them during live streaming and log replay. Result presentation may read JSON-serializable `result.meta`, which persists with the result; `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the rationale.
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
### Code Mode

View File

@@ -10,7 +10,7 @@ import { inspect } from 'node:util'
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 type {} 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'
@@ -111,9 +111,8 @@ function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unkno
return { dispatched: JSON.parse(text) as unknown, logged: JSON.parse(text) as unknown }
}
/** Render the program's completion value for the model-facing result text (`''` when the program returned nothing). */
function renderValue(value: unknown): string {
if (value === undefined) return ''
/** Render one present program completion value for the model-facing result text. */
function renderValue(value: JsonValue): string {
return typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
}
@@ -122,6 +121,9 @@ interface RunCodeMeta {
logs: CodeRunResult['logs']
}
/** Canonical value returned by the outer Code Mode transport. */
type RunCodeOutput = { logs: string[]; result?: JsonValue }
/** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */
function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
if (typeof meta !== 'object' || meta === null) return undefined
@@ -152,7 +154,23 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
parameters: {
code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
},
async execute(args, exec) {
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)' }]
},
presentationMeta: (_args, value) => ({ logs: value.logs }),
},
async execute(args, exec): Promise<RunCodeOutput> {
const runtime = requireRuntime()
// The run-scoped abort: follows the outer signal in, and fires when the
@@ -265,12 +283,12 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
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}`)
}
const rendered = renderValue(result.value)
const parts = [result.logs.join('\n'), rendered].filter(part => part.length > 0)
const meta: RunCodeMeta = { logs: result.logs }
// The runtime seam is wider than JSON until PR 3 makes this boundary
// lossless. The registry immediately snapshots and rejects any value
// that does not satisfy the declared JSON output.
return {
content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }],
meta,
logs: result.logs,
...result.value !== undefined ? { result: result.value as JsonValue } : {},
}
} finally {
exec.signal?.removeEventListener('abort', onOuterAbort)

View File

@@ -12,12 +12,15 @@ import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
// augmentation. The seam stays optional at runtime — see `serviceAsk`.
import type {} from '@deepseek-ai/dsh-user-approval'
import type { ToolCallView, ToolResultView } from './presentation.ts'
import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts'
import type { JsonSchemaNode } from './json-schema.ts'
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
import { renderToolsSdk } from './ts-types.ts'
@@ -61,6 +64,7 @@ export type { JsonValue } from '@deepseek-ai/dsh-session'
export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts'
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
@@ -132,12 +136,22 @@ declare module 'cordis' {
}
}
/** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */
export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
/** Tool-owned canonical output contract used after the body returns a JSON value. */
export interface ToolOutputDefinition {
/** Raw supported JSON Schema enforced against every successful canonical value. */
readonly schema: JsonSchemaNode
/** Pure projection from validated arguments and value to Native/model content. */
render(args: unknown, value: JsonValue): ContentBlock[]
/** Pure replayable presentation projection, computed only for surface calls. */
presentationMeta?(args: unknown, value: JsonValue): JsonValue
}
/** A registered tool: its schema plus the execution function. */
export interface ToolDefinition extends ToolSchema {
execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
/** Mandatory canonical output declaration. */
readonly output: ToolOutputDefinition
/** Execute the tool and return only its canonical lossless-JSON value. */
execute(args: unknown, exec: ToolRunContext): Promise<unknown>
/**
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
@@ -172,7 +186,7 @@ export interface ToolDefinition extends ToolSchema {
presentCall?(args: unknown): ToolCallView | undefined
/**
* Optional: how to present the COMPLETED state, given the same `args` and the
* `result` (`execute`'s content + whether it errored). Returns a
* durable result projection (`content`, failure state, and optional `meta`). Returns a
* {@link ToolResultView}, or `undefined` (or omit the method) to keep the
* pending title and render the raw result content. Pure and side-effect-free
* for the same replay reason.
@@ -182,17 +196,16 @@ export interface ToolDefinition extends ToolSchema {
/** The completed outcome handed to {@link ToolDefinition.presentResult}. */
export interface ToolResult {
/** The model-facing content `execute` returned (or the error text on failure). */
/** The final model-facing content (or the rendered error text on failure). */
content: ContentBlock[]
/** Whether the call failed. */
isError: boolean
/**
* The tool-private presentation payload the tool attached from `execute` (via
* the object return form), threaded verbatim from the `tool/result` event.
* Opaque (`unknown`); the tool narrows it back to its own shape. Absent when
* the tool attached none.
* The tool-private presentation payload projected by its output declaration
* and threaded verbatim from the `tool/result` event. Absent when the tool
* declared no projector or the call was nested under a composite transport.
*/
meta?: unknown
meta?: JsonValue
}
declare const toolExecutionTokenBrand: unique symbol
@@ -303,6 +316,14 @@ export interface ToolErrorInfo {
code: string
}
/** Canonical failure detail; internal routing information remains optional. */
export interface ToolFailure {
/** Human-readable failure message without the Native `Error: ` envelope. */
message: string
/** Internal error class/code used by policy and durable diagnostics. */
info?: ToolErrorInfo
}
/**
* Thrown (internally) when the model requests a tool that isn't registered.
* Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool
@@ -316,30 +337,42 @@ export class ToolNotFoundError extends HarnessError {
}
}
/** The outcome of one tool call. */
export interface ToolExecutionResult {
content: ContentBlock[]
isError: boolean
/**
* Set when the call failed with a {@link HarnessError}: machine-routable
* `{ name, code }` for retry/sandbox plugins and replay. The model-facing
* text in `content` is always present; this is extra structure for code.
*/
error?: ToolErrorInfo
/**
* Model-facing context for the next request, separate from this tool result. The loop
* accepts it into the active-batch FIFO, then appends after recorded results even if interrupted.
*/
additionalContexts?: HookContext[]
/**
* The tool-private presentation payload from a successful `execute` (the object
* return form). Threaded onto the `tool/result` session event and back into
* {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the
* tool attached none or the call failed.
*/
meta?: unknown
/** Thrown when a tool body or post-policy value violates its declared output. */
export class ToolOutputError extends HarnessError {
/** Schema/value violations in validation order. */
readonly violations: string[]
constructor(toolName: string, violations: string[]) {
super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT')
this.name = 'ToolOutputError'
this.violations = violations
}
}
/** Successful canonical tool execution, including its Native/model projection. */
export interface ToolExecutionSuccess {
readonly isError: false
/** Execution-local canonical value; deliberately omitted from durable events. */
readonly value: JsonValue
readonly content: ContentBlock[]
readonly error?: never
readonly meta?: JsonValue
readonly additionalContexts?: HookContext[]
}
/** Failed canonical tool execution; failures never carry a successful value. */
export interface ToolExecutionFailure {
readonly isError: true
readonly error: ToolFailure
readonly value?: never
readonly content: ContentBlock[]
readonly meta?: JsonValue
readonly additionalContexts?: HookContext[]
}
/** The discriminated, execution-local outcome of one tool call. */
export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure
/**
* Pre-dispatch decision. `allow` runs the call; `deny` materializes an error;
* `ask` runs only after an approval service returns `allowed-once` and otherwise
@@ -352,11 +385,12 @@ export type PreToolDecision =
| { kind: 'ask'; reason?: string }
/**
* Post-dispatch decision: accept or replace content, attach context for the next
* request, or block by turning corrective feedback into an error result.
* Post-dispatch decision: accept, replace one projection, attach context for the
* next request, or block by turning corrective feedback into an error result.
*/
export type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] }
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] }
/**
@@ -381,6 +415,23 @@ function errorMessage(error: unknown): string {
}
}
/** Derive one failure message from policy feedback without changing its rendered blocks. */
function failureMessageFromContent(content: ContentBlock[]): string {
const text = content
.map(block => block.type === 'text' ? block.text : `[${block.type} content]`)
.join('\n')
return text.length > 0 ? text : 'tool result blocked by post-execute policy'
}
/** Snapshot and freeze one durable tool-result projection or reject lossy data. */
function materializePresentation<T>(candidate: T): T {
const detached = snapshotJsonValue(candidate)
if (detached === undefined) {
throw new TypeError('tool result must be losslessly JSON-serializable')
}
return deepFreeze(detached)
}
/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
function errorInfo(error: unknown): ToolErrorInfo | undefined {
try {
@@ -553,6 +604,13 @@ export class ToolRegistry extends Service {
register(definition: ToolDefinition): () => void {
const scope = scopeOf(this.ctx)
const name = definition.name
const output = (definition as Partial<ToolDefinition>).output
if (output === undefined || typeof output !== 'object'
|| typeof output.render !== 'function'
|| (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function')) {
throw new TypeError(`tool "${name}" must declare output { schema, render, presentationMeta? }`)
}
assertSupportedJsonSchema(output.schema)
const timeoutMs = definition.timeoutMs
if (timeoutMs !== undefined
&& (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
@@ -880,10 +938,11 @@ export class ToolRegistry extends Service {
return await next({
kind: 'post-result',
exec,
result: {
result: this.materializeFinalResult({
content: [{ type: 'text', text: `Error: ${denialReason}` }],
isError: true,
},
error: { message: denialReason },
}),
})
}
return await next({ kind: 'dispatch', exec })
@@ -909,27 +968,26 @@ export class ToolRegistry extends Service {
const tool = this.get(exec.name, exec.agent)
if (!tool) throw new ToolNotFoundError(exec.name)
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
return { content, isError: false, ...meta !== undefined ? { meta } : {} }
return this.createSuccessResult(exec, tool, returned)
} catch (error: unknown) {
return toolErrorResult(error)
return this.materializeFinalResult(toolErrorResult(error))
}
},
)
const normalized = this.normalizeDispatchResult(exec, result)
const deferredContexts = this.deferredContexts.get(exec)
/* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution')
const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
? result
: {
...result,
? normalized
: this.markCanonical({
...normalized,
additionalContexts: [
...deferredContexts,
...result.additionalContexts ?? [],
...normalized.additionalContexts ?? [],
],
}
return { kind: 'post-result', result: resultWithDeferredContexts }
})
return { kind: 'post-result', result: this.materializeFinalResult(resultWithDeferredContexts) }
} catch (error: unknown) {
return { kind: 'final-result', result: toolErrorResult(error) }
}
@@ -1046,32 +1104,103 @@ export class ToolRegistry extends Service {
)
const decisionContexts = decision.additionalContexts ?? []
if (decision.kind === 'block') {
return {
const message = failureMessageFromContent(decision.feedback)
return this.markCanonical({
content: decision.feedback,
isError: true,
error: { message },
...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},
}
})
}
if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) {
throw new TypeError('tools/post-execute accept decision cannot replace both value and content')
}
// Accept: replace content if supplied, preserve the dispatched outcome, and
// append decision contexts after contexts deferred by the tool body.
const additionalContexts = [
...result.additionalContexts ?? [],
...decisionContexts,
]
return {
...result,
...decision.content ? { content: decision.content } : {},
...additionalContexts.length > 0 ? { additionalContexts } : {},
if (Object.hasOwn(decision, 'value')) {
if (result.isError) {
throw new TypeError('tools/post-execute cannot replace the value of a failed result')
}
const tool = this.get(exec.name, exec.agent)
if (tool === undefined) throw new ToolNotFoundError(exec.name)
const replaced = this.createSuccessResult(exec, tool, decision.value)
return this.markCanonical({
...replaced,
...additionalContexts.length > 0 ? { additionalContexts } : {},
})
}
return this.markCanonical({
...result,
...decision.content !== undefined ? { content: decision.content } : {},
...additionalContexts.length > 0 ? { additionalContexts } : {},
})
}
/** Results created by the registry already own a validated, frozen canonical value. */
private readonly canonicalResults = new WeakSet<object>()
/** Mark a registry-normalized result without freezing presentation fields prematurely. */
private markCanonical<T extends ToolExecutionResult>(result: T): T {
this.canonicalResults.add(result)
return result
}
/** Snapshot, validate, render, and optionally project one successful body value. */
private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess {
const detached = snapshotJsonValue(candidate)
if (detached === undefined) {
throw new ToolOutputError(tool.name, ['value is not lossless JSON'])
}
const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
if (violations.length > 0) throw new ToolOutputError(tool.name, violations)
const value = deepFreeze(detached as JsonValue)
const content = tool.output.render(exec.arguments, value)
const meta = exec.parent === undefined && tool.output.presentationMeta !== undefined
? tool.output.presentationMeta(exec.arguments, value)
: undefined
return this.markCanonical(this.materializeFinalResult({
isError: false,
value,
content,
...meta !== undefined ? { meta } : {},
}) as ToolExecutionSuccess)
}
/** Normalize an around-dispatch wrapper's authored result through the owning output contract. */
private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult {
if (this.canonicalResults.has(result)) return result
if (result.isError) {
return this.markCanonical({
isError: true,
error: result.error,
content: result.content,
...result.meta !== undefined ? { meta: result.meta } : {},
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
})
}
const tool = this.get(exec.name, exec.agent)
if (tool === undefined) throw new ToolNotFoundError(exec.name)
const normalized = this.createSuccessResult(exec, tool, result.value)
return this.markCanonical({
...normalized,
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
})
}
/** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult {
const detached = snapshotJsonValue(result)
if (detached === undefined) {
throw new TypeError('tool result must be losslessly JSON-serializable')
const presentation = {
content: result.content,
...result.meta !== undefined ? { meta: result.meta } : {},
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
}
return deepFreeze(detached)
if (result.isError) {
return materializePresentation({ isError: true as const, error: result.error, ...presentation })
}
const detached = materializePresentation({ isError: false as const, ...presentation })
return deepFreeze({ ...detached, value: result.value })
}
}
@@ -1082,10 +1211,11 @@ function createExecutionToken(): ToolExecutionToken {
function toolErrorResult(error: unknown): ToolExecutionResult {
const info = errorInfo(error)
const message = errorMessage(error)
return {
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true,
...info ? { error: info } : {},
error: { message, ...info ? { info } : {} },
}
}

View File

@@ -1,8 +1,9 @@
/** Unified JSON-value schema DSL, inference, compilation, and typed tool helper. @module dsh-tools/schema */
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts'
import type { ToolDefinition, ToolRunContext, ToolResult } from './index.ts'
import { assertSupportedJsonSchema, isPlainJsonRecord, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts'
import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts'
import type { ToolCallView, ToolResultView } from './presentation.ts'
@@ -114,21 +115,25 @@ type RequiredKeys<S extends ParameterSchemaSpec> = {
[K in keyof S]: S[K] extends { required: true } ? K : never
}[keyof S]
/** Advance the bounded inference walk through one nested schema node. */
type NextDepth<D extends readonly unknown[]> = readonly [...D, unknown]
/** Infer the declared value of one parameter property without key optionality. */
type InferProperty<P extends ParameterPropertySpec> = P extends ValueSchemaSpec ? InferValue<P> : never
type InferProperty<P extends ParameterPropertySpec, D extends readonly unknown[]> =
P extends ValueSchemaSpec ? InferValue<P, D> : never
/** Infer an implicit property map into required and optional object keys. */
type InferProperties<S extends ParameterSchemaSpec> = Simplify<
& { [K in RequiredKeys<S>]: InferProperty<S[K]> }
& { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferProperty<S[K]> }
type InferProperties<S extends ParameterSchemaSpec, D extends readonly unknown[]> = Simplify<
& { [K in RequiredKeys<S>]: InferProperty<S[K], D> }
& { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferProperty<S[K], D> }
>
/** Infer an explicit object node, including its declared openness. */
type InferObject<S extends ObjectValueSchemaSpec> =
type InferObject<S extends ObjectValueSchemaSpec, D extends readonly unknown[]> =
S extends { properties: infer P extends ParameterSchemaSpec }
? S['additionalProperties'] extends true
? InferProperties<P> & Record<string, JsonValue>
: InferProperties<P>
? InferProperties<P, D> & Record<string, JsonValue>
: InferProperties<P, D>
: S['additionalProperties'] extends true
? Record<string, JsonValue>
: Record<string, never>
@@ -143,20 +148,21 @@ type InferScalar<S, Fallback> =
* Infer the TypeScript value accepted by an author-facing value schema.
* Output schemas may therefore infer object, array, scalar, or null roots.
*/
export type InferValue<S extends ValueSchemaSpec> =
S extends StringValueSchemaSpec ? InferScalar<S, string> :
S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar<S, number> :
S extends BooleanValueSchemaSpec ? InferScalar<S, boolean> :
S extends NullValueSchemaSpec ? null :
S extends ArrayValueSchemaSpec
? S extends { items: infer I extends ValueSchemaSpec } ? InferValue<I>[] : JsonValue[]
: S extends ObjectValueSchemaSpec ? InferObject<S> :
S extends JsonValueSchemaSpec ? JsonValue :
S extends OneOfValueSchemaSpec ? InferValue<S['oneOf'][number]> :
never
export type InferValue<S extends ValueSchemaSpec, D extends readonly unknown[] = readonly []> =
D['length'] extends 12 ? JsonValue :
S extends StringValueSchemaSpec ? InferScalar<S, string> :
S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar<S, number> :
S extends BooleanValueSchemaSpec ? InferScalar<S, boolean> :
S extends NullValueSchemaSpec ? null :
S extends ArrayValueSchemaSpec
? S extends { items: infer I extends ValueSchemaSpec } ? InferValue<I, NextDepth<D>>[] : JsonValue[]
: S extends ObjectValueSchemaSpec ? InferObject<S, NextDepth<D>> :
S extends JsonValueSchemaSpec ? JsonValue :
S extends OneOfValueSchemaSpec ? InferValue<S['oneOf'][number], NextDepth<D>> :
never
/** Infer the TypeScript argument object for an implicit parameter schema. */
export type InferArgs<S extends ParameterSchemaSpec> = InferProperties<S>
export type InferArgs<S extends ParameterSchemaSpec> = InferProperties<S, readonly []>
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
@@ -329,13 +335,22 @@ export function validateArgs(spec: ParameterSchemaSpec, args: unknown): string[]
}
/** Options for {@link defineTool}. */
export interface DefineToolOptions<S extends ParameterSchemaSpec> {
export interface DefineToolOptions<S extends ParameterSchemaSpec, O extends ValueSchemaSpec> {
/** Tool name (must be unique). */
readonly name: string
/** Human-readable description sent to the model. */
readonly description: string
/** Per-property parameter schema compiled to an implicit open object root. */
readonly parameters: S
/** Canonical output schema plus pure Native and presentation projections. */
readonly output: {
/** Schema enforced against every successful body or policy-replaced value. */
readonly schema: O
/** Pure Native/model rendering of one validated canonical value. */
render(args: InferArgs<S>, value: InferValue<NoInfer<O>>): ContentBlock[]
/** Pure replayable presentation metadata for direct surface calls. */
presentationMeta?(args: InferArgs<S>, value: InferValue<NoInfer<O>>): JsonValue
}
/** Optional positive cooperative timeout budget in milliseconds. */
readonly timeoutMs?: number
/**
@@ -348,9 +363,9 @@ export interface DefineToolOptions<S extends ParameterSchemaSpec> {
* Execute the tool after argument validation.
* @param args - typed validated arguments.
* @param exec - execution identity, caller, cancellation, and nesting data.
* @returns Model-facing content and optional presentation metadata.
* @returns The canonical value declared by `output.schema`.
*/
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<ToolExecuteReturn>
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<InferValue<NoInfer<O>>>
/**
* Pure pending-state presenter.
* @param args - typed validated arguments.
@@ -373,11 +388,17 @@ export interface DefineToolOptions<S extends ParameterSchemaSpec> {
* @param options - typed definition and optional presenters.
* @returns A registry-ready definition.
*/
export function defineTool<S extends ParameterSchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
export function defineTool<const S extends ParameterSchemaSpec, const O extends ValueSchemaSpec>(
options: DefineToolOptions<S, O>,
): ToolDefinition {
// Object-literal methods do not use `this`; retaining references is safe.
// eslint-disable-next-line @typescript-eslint/unbound-method
const userExecute = options.execute
// eslint-disable-next-line @typescript-eslint/unbound-method
const userRender = options.output.render
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentationMeta = options.output.presentationMeta
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentCall = options.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentResult = options.presentResult
@@ -387,16 +408,28 @@ export function defineTool<S extends ParameterSchemaSpec>(options: DefineToolOpt
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
}
const parameters = parameterSchemaSpecToJsonSchema(options.parameters)
const outputSchema = valueSchemaSpecToJsonSchema(options.output.schema)
const validate = (args: unknown): string[] => validateJsonSchemaValue(parameters, args, '')
const tool: ToolDefinition = {
name: options.name,
description: options.description,
parameters: parameters as unknown as Record<string, unknown>,
output: {
schema: outputSchema,
render(args: unknown, value: JsonValue): ContentBlock[] {
return userRender(args as InferArgs<S>, value as unknown as InferValue<NoInfer<O>>)
},
...userPresentationMeta !== undefined ? {
presentationMeta(args: unknown, value: JsonValue): JsonValue {
return userPresentationMeta(args as InferArgs<S>, value as unknown as InferValue<NoInfer<O>>)
},
} : {},
},
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
async execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn> {
async execute(args: unknown, exec: ToolRunContext): Promise<JsonValue> {
const violations = validate(args)
if (violations.length > 0) throw new ToolArgsError(violations)
return userExecute(args as InferArgs<S>, exec)
return userExecute(args as InferArgs<S>, exec) as Promise<JsonValue>
},
}
if (userPresentCall) {

View File

@@ -0,0 +1,42 @@
/** Canonical tool-definition fixtures for repository tests. @module dsh-tools/testing */
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import { defineTool } from './schema.ts'
import type { DefineToolOptions, ParameterSchemaSpec } from './schema.ts'
import type { ToolDefinition, ToolRunContext } from './index.ts'
const CONTENT_VALUE_SCHEMA = { type: 'array', items: { type: 'json' } } as const
/** Options for a fixture whose canonical value is its rendered content array. */
export type ContentToolFixtureOptions<S extends ParameterSchemaSpec> = Omit<
DefineToolOptions<S, typeof CONTENT_VALUE_SCHEMA>,
'output' | 'execute'
> & {
/** Produce the fixture's content blocks as its canonical test value. */
execute(args: import('./schema.ts').InferArgs<S>, exec: ToolRunContext): Promise<ContentBlock[]>
}
/**
* Define a test fixture that deliberately uses its content blocks as the
* canonical JSON value. Product tools must declare domain-owned DTOs instead.
* @param options - ordinary fixture fields plus a content-producing body.
* @returns a registry-ready tool with an explicit JSON-array output contract.
* @internal
*/
export function defineContentToolFixture<const S extends ParameterSchemaSpec>(
options: ContentToolFixtureOptions<S>,
): ToolDefinition {
// eslint-disable-next-line @typescript-eslint/unbound-method
const execute = options.execute
return defineTool({
...options,
output: {
schema: CONTENT_VALUE_SCHEMA,
render: (_args, value) => value as unknown as ContentBlock[],
},
async execute(args, exec) {
return await execute(args, exec) as unknown as JsonValue[]
},
})
}

View File

@@ -6,7 +6,7 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -68,7 +68,7 @@ async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: S
/** Register a trivial echo tool; returns the calls it received. */
function registerEcho(ctx: Context, name = 'echo'): unknown[] {
const calls: unknown[] = []
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name,
description: `Echo tool ${name}.`,
parameters: { value: { type: 'string', required: true } },
@@ -217,7 +217,7 @@ describe('mode-aware wire contribution', () => {
it.each(['code', 'both'] as const)('reserves run_code against scoped shadows and explicit restrictions in mode %s', async (mode) => {
const { ctx, systemPrompt } = await setup({ mode })
const { scope, agent } = await mintAgentScope(ctx)
const impostor = defineTool({
const impostor = defineContentToolFixture({
name: RUN_CODE_NAME,
description: 'Scoped impostor.',
parameters: {},
@@ -229,7 +229,7 @@ describe('mode-aware wire contribution', () => {
expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
scope.ctx.systemPrompt.section({ name: 'scoped-note', order: 149, text: 'safe note' })
scope.ctx.tools.register(defineTool({
scope.ctx.tools.register(defineContentToolFixture({
name: 'scoped_safe',
description: 'Safe scoped tool.',
parameters: {},
@@ -332,6 +332,8 @@ describe('the run_code dispatch bridge', () => {
}
const result = await runCode(ctx, 'const …: string = …', { agent })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected run_code success')
expect(result.value).toEqual({ logs: ['saw echo:one'], result: 'echo:two' })
expect(result.content).toEqual([{ type: 'text', text: 'saw echo:one\necho:two' }])
expect(calls).toEqual([{ value: 'one' }, { value: 'two' }])
const dispatches = events.filter(event => event.type === 'tool/code-dispatch')
@@ -374,7 +376,7 @@ describe('the run_code dispatch bridge', () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const intervals: [string, string][] = []
let active = 0
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'probe',
description: 'Records execution overlap.',
parameters: { id: { type: 'string', required: true } },
@@ -405,7 +407,7 @@ describe('the run_code dispatch bridge', () => {
it('rejects the program-side call when the tool errors, with the tool error text', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'fail',
description: 'Always fails.',
parameters: {},
@@ -549,7 +551,7 @@ describe('the run_code dispatch bridge', () => {
})
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' })
expect(result.error).toMatchObject({ info: { name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' } })
const text = (result.content[0] as { text: string }).text
expect(text).toContain('code run failed (timeout)')
expect(text).toContain('compute budget exhausted')
@@ -566,7 +568,7 @@ describe('the run_code dispatch bridge', () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const seen: string[] = []
let sawAbort = false
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'slow',
description: 'Slow tool observing its signal.',
parameters: { id: { type: 'string', required: true } },
@@ -602,7 +604,7 @@ describe('the run_code dispatch bridge', () => {
let sawAbort = false
let started!: () => void
const inFlight = new Promise<void>((resolve) => { started = resolve })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'slow',
description: 'Slow tool observing its signal.',
parameters: { id: { type: 'string', required: true } },
@@ -689,7 +691,7 @@ describe('the run_code dispatch bridge', () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const { agent, events } = fakeAgent()
const long = 'x'.repeat(300)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'mixed',
description: 'Returns mixed content.',
parameters: {},
@@ -714,7 +716,7 @@ describe('the run_code dispatch bridge', () => {
it('normalizes the session workspace root before bounding durable result summaries', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'workspace_path',
description: 'Return a path beneath the session workspace.',
parameters: {},
@@ -792,7 +794,7 @@ describe('the run_code dispatch bridge', () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const { agent, events } = fakeAgent()
let mutationSucceeded: boolean | undefined
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'mutator',
description: 'Attempts to mutate its args object.',
parameters: { list: { type: 'array', required: true } },
@@ -814,7 +816,7 @@ describe('the run_code dispatch bridge', () => {
it('exposes a tool named __proto__ as an ordinary own binding', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: '__proto__',
description: 'A prototype-colliding tool name.',
parameters: {},

View File

@@ -5,7 +5,7 @@ import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool,
defineContentToolFixture,
type ToolDefinition,
type ToolExecutionInput,
type ToolExecutionMode,
@@ -25,7 +25,7 @@ function exec(name: string, args: unknown): ToolExecutionInput {
describe('ToolRegistry.executionMode', () => {
it('returns parallel only for an explicit true classifier', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'safe',
description: 'parallel-safe',
parameters: {},
@@ -37,7 +37,7 @@ describe('ToolRegistry.executionMode', () => {
it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'plain',
description: 'no declaration',
parameters: {},
@@ -53,7 +53,7 @@ describe('ToolRegistry.executionMode', () => {
it('returns exclusive when the classifier returns false for these args', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'rw',
description: 'read or write',
parameters: { mode: { type: 'string', required: true } },
@@ -64,9 +64,9 @@ describe('ToolRegistry.executionMode', () => {
expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' })
})
it('classifies invalid defineTool arguments as exclusive without throwing', async () => {
it('classifies invalid defineContentToolFixture arguments as exclusive without throwing', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'needs-mode',
description: 'requires mode',
parameters: { mode: { type: 'string', required: true } },
@@ -82,8 +82,9 @@ describe('ToolRegistry.executionMode', () => {
name: 'thrower',
description: 'classifier throws',
parameters: { type: 'object', properties: {} },
output: { schema: { type: 'null' }, render: () => [] },
isConcurrencySafe() { throw new Error('boom') },
async execute() { return [] },
async execute() { return null },
}
ctx.tools.register(raw)
expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' })
@@ -95,8 +96,9 @@ describe('ToolRegistry.executionMode', () => {
name: 'truthy',
description: 'classifier returns a truthy string',
parameters: { type: 'object', properties: {} },
output: { schema: { type: 'null' }, render: () => [] },
isConcurrencySafe() { return 'yes' },
async execute() { return [] },
async execute() { return null },
} as unknown as ToolDefinition
ctx.tools.register(raw)
expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' })
@@ -109,8 +111,9 @@ describe('ToolRegistry.executionMode', () => {
name: 'raw-safe',
description: 'raw',
parameters: { type: 'object', properties: {} },
output: { schema: { type: 'null' }, render: () => [] },
isConcurrencySafe(args) { seen = args; return true },
async execute() { return [] },
async execute() { return null },
})
expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' })
expect(seen).toEqual({ anything: 1 })
@@ -118,7 +121,7 @@ describe('ToolRegistry.executionMode', () => {
it('isConcurrencySafe never reaches the model-facing schemas() projection', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'safe',
description: 'parallel-safe',
parameters: { x: { type: 'string', required: true } },

View File

@@ -9,7 +9,6 @@ import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
/** Mount the registry (with its systemPrompt dependency) on a fresh context. */
@@ -37,7 +36,11 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition {
name,
description: `tool ${name}`,
parameters: { type: 'object', properties: {} },
execute: (): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text: reply }]),
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value as string }],
},
execute: (): Promise<string> => Promise.resolve(reply),
}
}
@@ -221,7 +224,7 @@ describe('scoped execution dispatch', () => {
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
return Promise.resolve('ran:t')
},
})
const guard = (execution: Readonly<ToolExecution>): string => {
@@ -253,7 +256,7 @@ describe('scoped execution dispatch', () => {
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([])
return Promise.resolve('ran:t')
},
})
ctx.tools.guard(() => undefined)
@@ -276,14 +279,14 @@ describe('scoped execution dispatch', () => {
execute: (args) => {
safeCalls += 1
safeArguments = args
return Promise.resolve([{ type: 'text', text: 'safe' }])
return Promise.resolve('safe')
},
})
ctx.tools.register({
...tool('danger'),
execute: () => {
dangerCalls += 1
return Promise.resolve([{ type: 'text', text: 'danger' }])
return Promise.resolve('danger')
},
})
scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined)
@@ -335,7 +338,7 @@ describe('scoped execution dispatch', () => {
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([])
return Promise.resolve('ran:t')
},
})
ctx.on('tools/pre-execute', (_exec, next) => {
@@ -396,7 +399,7 @@ describe('scoped execution dispatch', () => {
...tool('t'),
execute: (_args, exec) => {
observed.push(exec.parent)
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
return Promise.resolve('ran:t')
},
})
ctx.on('tools/pre-execute', (exec, next) => {
@@ -511,7 +514,7 @@ describe('scoped execution dispatch', () => {
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([])
return Promise.resolve('ran:t')
},
})
ctx.on('tools/pre-execute', (_exec, next) => {
@@ -552,6 +555,7 @@ describe('scoped execution dispatch', () => {
expect(result).toEqual({
content: [{ type: 'text', text: 'ran:t' }],
isError: false,
value: 'ran:t',
})
})
@@ -570,6 +574,7 @@ describe('scoped execution dispatch', () => {
return {
content: [{ type: 'text', text: 'outer failure' }],
isError: true,
error: { message: 'outer failure' },
}
}, { prepend: true })
scope.ctx.on('tools/result', (_exec, result) => {

View File

@@ -5,9 +5,9 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { Agent } from '@deepseek-ai/dsh-agent'
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import ToolRegistry, {
defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
defineContentToolFixture, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult,
type ToolDefinition, type ToolExecution, type ToolExecutionResult, type ToolExecutionToken,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -21,8 +21,12 @@ const echoTool = defineTool({
name: 'echo',
description: 'echo arguments back',
parameters: { text: { type: 'string' } },
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return [{ type: 'text' as const, text: args.text ?? '' }]
return args.text ?? ''
},
})
@@ -50,7 +54,7 @@ describe('ToolRegistry', () => {
// the system-prompt assembly → the model request, so those callbacks (and
// `execute`) must be stripped: a function in the JSON tool schema would
// corrupt the request. schemas() is an explicit allowlist, so it can't leak.
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'present',
description: 'has presenters',
parameters: { x: { type: 'string', required: true } },
@@ -67,7 +71,7 @@ describe('ToolRegistry', () => {
it('schemas() excludes timeoutMs — the budget must never reach the model', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'budgeted', description: 'has a budget', parameters: {}, timeoutMs: 5_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
}))
@@ -79,17 +83,24 @@ describe('ToolRegistry', () => {
it('executes a tool and returns its content', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let observed: ToolExecutionResult | undefined
ctx.on('tools/result', (_exec, result) => { observed = result })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: 'hi' })
expect(observed).toEqual(result)
})
it('threads a tool-attached meta (object return form) onto the result', async () => {
it('projects presentation metadata from the canonical value', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'meta-tool',
output: {
...echoTool.output,
presentationMeta: () => ({ diffs: [{ path: 'a', oldText: null, newText: 'x' }] }),
},
async execute() {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } }
return 'ok'
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} })
@@ -97,20 +108,21 @@ describe('ToolRegistry', () => {
content: [{ type: 'text', text: 'ok' }],
isError: false,
meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] },
value: 'ok',
})
})
it('omits meta when the object return form supplies none', async () => {
it('omits meta when no presentation projector is declared', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'no-meta-tool',
async execute() {
return { content: [{ type: 'text', text: 'ok' }] }
return 'ok'
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false, value: 'ok' })
expect('meta' in result).toBe(false)
})
@@ -121,8 +133,12 @@ describe('ToolRegistry', () => {
ctx.tools.register({
...echoTool,
name: 'bad-meta',
output: {
...echoTool.output,
presentationMeta: () => (() => undefined) as unknown as JsonValue,
},
async execute() {
return { content: [], meta: () => undefined }
return 'ok'
},
})
@@ -134,6 +150,284 @@ describe('ToolRegistry', () => {
expect(observedError).toBe(true)
})
it('requires every raw registration to declare its canonical output', async () => {
const ctx = await setup()
const missingOutput = {
name: 'legacy-content-tool',
description: 'missing output',
parameters: {},
execute: async () => [{ type: 'text', text: 'legacy' }],
} as unknown as ToolDefinition
expect(() => ctx.tools.register(missingOutput))
.toThrow('must declare output { schema, render, presentationMeta? }')
})
it('rejects lossy and schema-mismatched body values before post-execute', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'lossy-output',
description: 'lossy',
parameters: {},
output: { schema: { type: 'json' }, render: () => [] },
execute: async () => (() => undefined) as unknown as JsonValue,
}))
ctx.tools.register(defineTool({
name: 'wrong-output',
description: 'wrong schema',
parameters: {},
output: { schema: { type: 'string' }, render: () => [] },
execute: async () => 42 as unknown as string,
}))
const lossy = await ctx.tools.execute({ callId: CallId('lossy'), name: 'lossy-output', arguments: {} })
const mismatch = await ctx.tools.execute({ callId: CallId('mismatch'), name: 'wrong-output', arguments: {} })
expect(lossy.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } })
expect(lossy.content[0]?.type === 'text' ? lossy.content[0].text : '').toContain('not lossless JSON')
expect(mismatch.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } })
expect(mismatch.content[0]?.type === 'text' ? mismatch.content[0].text : '').toContain('"value" must be a string')
})
it.each(['render', 'presentationMeta'] as const)('contains a throwing output.%s projector as one failed call', async (projector) => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: `throwing-${projector}`,
description: projector,
parameters: {},
output: {
schema: { type: 'string' },
render: () => {
if (projector === 'render') throw new Error('renderer exploded')
return [{ type: 'text', text: 'ok' }]
},
presentationMeta: () => {
if (projector === 'presentationMeta') throw new Error('metadata exploded')
return null
},
},
execute: async () => 'ok',
}))
const result = await ctx.tools.execute({ callId: CallId(projector), name: `throwing-${projector}`, arguments: {} })
expect(result).toMatchObject({
isError: true,
error: { message: projector === 'render' ? 'renderer exploded' : 'metadata exploded' },
})
expect('value' in result).toBe(false)
})
it('keeps value/meta through content replacement and recomputes both projections after value replacement', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'projected',
description: 'projected',
parameters: {},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: { text: { type: 'string', required: true } },
},
render: (_args, value) => [{ type: 'text', text: `render:${value.text}` }],
presentationMeta: (_args, value) => ({ projected: value.text }),
},
execute: async () => ({ text: 'body' }),
}))
let replacement: 'content' | 'value' = 'content'
ctx.on('tools/post-execute', async () => {
if (replacement === 'content') {
return { kind: 'accept', content: [{ type: 'text', text: 'policy content' }] }
}
return {
kind: 'accept',
value: { text: 'policy value' },
additionalContexts: [{ content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' } }],
}
})
const content = await ctx.tools.execute({ callId: CallId('content'), name: 'projected', arguments: {} })
replacement = 'value'
const value = await ctx.tools.execute({ callId: CallId('value'), name: 'projected', arguments: {} })
expect(content).toEqual({
isError: false,
value: { text: 'body' },
content: [{ type: 'text', text: 'policy content' }],
meta: { projected: 'body' },
})
expect(value).toEqual({
isError: false,
value: { text: 'policy value' },
content: [{ type: 'text', text: 'render:policy value' }],
meta: { projected: 'policy value' },
additionalContexts: [{ content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' } }],
})
})
it('fails a post-execute decision that replaces both projections or supplies an invalid value', async () => {
const both = await setup()
both.tools.register(echoTool)
both.on('tools/post-execute', async () => ({
kind: 'accept',
value: 'replacement',
content: [{ type: 'text', text: 'also replacement' }],
} as unknown as PostToolDecision))
const bothResult = await both.tools.execute({ callId: CallId('both'), name: 'echo', arguments: {} })
expect(bothResult).toMatchObject({
isError: true,
error: { message: 'tools/post-execute accept decision cannot replace both value and content' },
})
const invalid = await setup()
invalid.tools.register(echoTool)
invalid.on('tools/post-execute', async () => ({ kind: 'accept', value: 1 }))
const invalidResult = await invalid.tools.execute({ callId: CallId('invalid'), name: 'echo', arguments: {} })
expect(invalidResult.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } })
expect('value' in invalidResult).toBe(false)
})
it('turns a post-execute block into a valueless failure', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => ({
kind: 'block',
feedback: [{ type: 'text', text: 'blocked by policy' }],
}))
const result = await ctx.tools.execute({ callId: CallId('block'), name: 'echo', arguments: { text: 'secret' } })
expect(result).toEqual({
isError: true,
error: { message: 'blocked by policy' },
content: [{ type: 'text', text: 'blocked by policy' }],
})
expect('value' in result).toBe(false)
})
it('replaces a canonical value without manufacturing additional context', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' }))
const result = await ctx.tools.execute({ callId: CallId('replace-value'), name: 'echo', arguments: {} })
expect(result).toEqual({
isError: false,
value: 'replacement',
content: [{ type: 'text', text: 'replacement' }],
})
})
it.each([
[[], 'tool result blocked by post-execute policy'],
[[{ type: 'reasoning', text: 'private rationale' }], '[reasoning content]'],
] as const)('derives a stable failure message from non-text or empty block feedback', async (feedback, message) => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => ({ kind: 'block', feedback: [...feedback] }))
const result = await ctx.tools.execute({ callId: CallId('block-message'), name: 'echo', arguments: {} })
expect(result.error?.message).toBe(message)
})
it('contains a non-JSON post-execute failure projection as a safe final error', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => ({
kind: 'block',
feedback: [{ type: 'text', text: 'blocked', invalid: () => undefined } as never],
}))
const result = await ctx.tools.execute({ callId: CallId('invalid-block'), name: 'echo', arguments: {} })
expect(result).toMatchObject({
isError: true,
error: { message: 'tool result must be losslessly JSON-serializable' },
})
})
it('rejects value replacement on a failed dispatch', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'throw-before-replace',
async execute() { throw new Error('body failed') },
})
ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' }))
const result = await ctx.tools.execute({
callId: CallId('failed-replace'), name: 'throw-before-replace', arguments: {},
})
expect(result.error?.message).toBe('tools/post-execute cannot replace the value of a failed result')
})
it('fails value replacement when the owning tool disappears before post-policy resolves', async () => {
const ctx = await setup()
const dispose = ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => {
dispose()
return { kind: 'accept', value: 'replacement' }
})
const result = await ctx.tools.execute({ callId: CallId('post-disposed'), name: 'echo', arguments: {} })
expect(result.error).toEqual({
message: 'unknown tool "echo"',
info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' },
})
})
it('normalizes wrapper-authored failure metadata and contexts', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => ({
isError: true,
error: { message: 'wrapped failure' },
content: [{ type: 'text', text: 'wrapper content' }],
meta: { wrapped: true },
additionalContexts: [{ content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' } }],
}))
const result = await ctx.tools.execute({ callId: CallId('wrapper-failure'), name: 'echo', arguments: {} })
expect(result).toEqual({
isError: true,
error: { message: 'wrapped failure' },
content: [{ type: 'text', text: 'wrapper content' }],
meta: { wrapped: true },
additionalContexts: [{ content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' } }],
})
})
it('fails wrapper-authored success normalization when the owning tool disappears', async () => {
const ctx = await setup()
const dispose = ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => {
dispose()
return { isError: false, value: 'replacement', content: [] }
})
const result = await ctx.tools.execute({ callId: CallId('wrapper-disposed'), name: 'echo', arguments: {} })
expect(result.error).toEqual({
message: 'unknown tool "echo"',
info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' },
})
})
it('suppresses presentation metadata only for nested composite dispatches', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'meta-suppression',
output: { ...echoTool.output, presentationMeta: () => ({ card: true }) },
})
const direct = await ctx.tools.execute({ callId: CallId('direct'), name: 'meta-suppression', arguments: {} })
const nested = await ctx.tools.execute({
callId: CallId('nested'),
name: 'meta-suppression',
arguments: {},
parent: Symbol('outer') as ToolExecutionToken,
})
expect(direct.meta).toEqual({ card: true })
expect(nested.meta).toBeUndefined()
expect(nested.isError ? undefined : nested.value).toBe('')
})
it('returns isError results for unknown tools and throwing tools', async () => {
const ctx = await setup()
ctx.tools.register({
@@ -148,7 +442,10 @@ describe('ToolRegistry', () => {
expect(unknown.isError).toBe(true)
expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' })
// An unknown tool is a routable failure class, same as a tool-thrown one.
expect(unknown.error).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' })
expect(unknown.error).toEqual({
message: 'unknown tool "nope"',
info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' },
})
const thrown = await ctx.tools.execute({ callId: CallId('c2'), name: 'boom', arguments: {} })
expect(thrown.isError).toBe(true)
@@ -189,15 +486,22 @@ describe('ToolRegistry', () => {
it('lets a tools/pre-execute listener deny a call (permission pattern)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let postSawFrozen = false
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.name === 'echo') return { kind: 'deny', reason: 'denied by policy' }
return next()
})
ctx.on('tools/post-execute', async (_exec, result, next) => {
postSawFrozen = Object.isFrozen(result)
expect(Reflect.set(result, 'content', [])).toBe(false)
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
expect(postSawFrozen).toBe(true)
})
it('an ask decision degrades to deny when no approval seam is mounted', async () => {
@@ -378,7 +682,7 @@ describe('ToolRegistry', () => {
it('preserves tool-deferred, execute-wrapper, and post-execute contexts in order', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'composite',
description: 'composite',
parameters: {},
@@ -422,7 +726,7 @@ describe('ToolRegistry', () => {
it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'failing-composite',
description: 'failing composite',
parameters: {},
@@ -473,7 +777,7 @@ describe('ToolRegistry', () => {
it('runs tools/execute after an allowed pre-execute, around dispatch, and before post-execute', async () => {
const ctx = await setup()
const order: string[] = []
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'traced',
description: 'echo',
parameters: { text: { type: 'string' } },
@@ -493,7 +797,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: [{ type: 'text', text: 'hi' }] })
// The around seam wraps dispatch; pre gates before it, post runs over its result.
expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
})
@@ -533,11 +837,35 @@ describe('ToolRegistry', () => {
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } })
expect(seen).toEqual({
isError: true,
error: { message: 'kaboom', info: { name: 'HarnessError', code: 'BOOM' } },
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
})
it('freezes core dispatch outcomes before around and post listeners can observe them', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const mutationAttempts: boolean[] = []
ctx.on('tools/execute', async (_exec, next) => {
const result = await next()
mutationAttempts.push(Reflect.set(result, 'value', 'around mutation'))
return result
})
ctx.on('tools/post-execute', async (_exec, result, next) => {
mutationAttempts.push(Reflect.set(result, 'value', 'post mutation'))
return next()
})
const result = await ctx.tools.execute({
callId: CallId('frozen-canonical'), name: 'echo', arguments: { text: 'original' },
})
expect(mutationAttempts).toEqual([false, false])
expect(result.isError ? undefined : result.value).toBe('original')
})
it('a thrown tool normalized inside tools/execute still reaches post-execute', async () => {
const ctx = await setup()
ctx.tools.register({
@@ -567,7 +895,7 @@ describe('ToolRegistry', () => {
name: 'signal-probe',
async execute(_args, exec) {
seenSignal = exec.signal
return [{ type: 'text' as const, text: 'ok' }]
return 'ok'
},
})
@@ -591,11 +919,11 @@ describe('ToolRegistry', () => {
ctx.tools.register({
...echoTool,
name: 'never-runs',
async execute() { dispatched = true; return [] },
async execute() { dispatched = true; return 'unreachable' },
})
ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
({ content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
({ content: [{ type: 'text', text: 'ignored authored content' }], isError: false, value: 'short-circuited' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
expect(dispatched).toBe(false) // returning without next() skips core dispatch
@@ -608,6 +936,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/execute', async () => ({
content: [{ type: 'text', text: 'short-circuited with context' }],
isError: false,
value: 'short-circuited with context',
additionalContexts: [{
content: [{ type: 'text', text: 'from around dispatch' }],
source: { kind: 'plugin', plugin: 'test' },
@@ -631,6 +960,7 @@ describe('ToolRegistry', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: wrapper broke' }],
error: { message: 'wrapper broke' },
isError: true,
})
})
@@ -646,6 +976,7 @@ describe('ToolRegistry', () => {
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: permission hook broke' }],
error: { message: 'permission hook broke' },
isError: true,
})
})
@@ -661,6 +992,7 @@ describe('ToolRegistry', () => {
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: post hook broke' }],
error: { message: 'post hook broke' },
isError: true,
})
})
@@ -676,7 +1008,7 @@ describe('ToolRegistry', () => {
expect(result).toMatchObject({
isError: true,
error: { name: 'HarnessError', code: 'DENIED' },
error: { message: 'denied', info: { name: 'HarnessError', code: 'DENIED' } },
})
})
@@ -839,10 +1171,14 @@ describe('defineTool / schema DSL', () => {
text: { type: 'string', required: true },
uppercase: { type: 'boolean' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
// args is typed: { text: string; uppercase?: boolean }
const result = args.uppercase ? args.text.toUpperCase() : args.text
return [{ type: 'text', text: result }]
return result
},
})
@@ -866,6 +1202,7 @@ describe('defineTool / schema DSL', () => {
arguments: { text: 'hello', uppercase: true },
})
expect(result.isError).toBe(false)
expect(result.isError ? undefined : result.value).toBe('HELLO')
expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }])
})
@@ -876,12 +1213,13 @@ describe('defineTool / schema DSL', () => {
name: 'type-check',
description: '',
parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } },
output: { schema: { type: 'string' }, render: () => [] },
async execute(args) {
// Verify types at runtime via typeof
expect(typeof args.a).toBe('string')
// args.b should be undefined when not provided
void args
return [{ type: 'text', text: args.a }]
return args.a
},
})
void tool
@@ -896,8 +1234,12 @@ describe('defineTool / schema DSL', () => {
req: { type: 'string', required: true },
opt: { type: 'number', description: 'Optional number' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return [{ type: 'text', text: `${args.req}:${args.opt ?? 'none'}` }]
return `${args.req}:${args.opt ?? 'none'}`
},
}))
@@ -933,9 +1275,13 @@ describe('defineTool / schema DSL', () => {
properties: { path: { type: 'string' } },
required: ['path'],
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value as string }],
},
async execute(args: unknown) {
const p = args as { path: string }
return [{ type: 'text', text: p.path }]
return p.path
},
})
@@ -1284,7 +1630,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
describe('defineTool validation (the runtime-validation Agent Note, part 1)', () => {
it('returns an isError result with the violations when the model sends bad args', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'reader',
description: 'reads a path',
parameters: { path: { type: 'string', required: true } },
@@ -1302,7 +1648,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
it('runs execute normally when args are valid', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'reader',
description: 'reads a path',
parameters: { path: { type: 'string', required: true } },
@@ -1311,7 +1657,11 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
},
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false })
expect(result).toEqual({
content: [{ type: 'text', text: 'read /x' }],
isError: false,
value: [{ type: 'text', text: 'read /x' }],
})
})
it('ToolArgsError carries a stable code and the violation list', () => {
@@ -1325,7 +1675,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
it('a schema-invalid call surfaces the structured error on the result', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'reader',
description: 'reads a path',
parameters: { path: { type: 'string', required: true } },
@@ -1335,7 +1685,10 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' })
expect(result.error).toEqual({
message: 'invalid arguments: missing required property "path"',
info: { name: 'ToolArgsError', code: 'INVALID_ARGS' },
})
})
it('a tool throwing a HarnessError surfaces its name and code', async () => {
@@ -1350,11 +1703,11 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' })
expect(result.error).toEqual({ message: 'disk full', info: { name: 'HarnessError', code: 'ENOSPC' } })
expect(result.content[0]).toMatchObject({ text: 'Error: disk full' })
})
it('a non-HarnessError throw has no structured error (only the text)', async () => {
it('a non-HarnessError throw retains only its message', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
@@ -1365,7 +1718,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toBeUndefined()
expect(result.error).toEqual({ message: 'just a message' })
expect(result.content[0]).toMatchObject({ text: 'Error: just a message' })
})
@@ -1376,8 +1729,12 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
name: 'raw',
description: 'raw tool',
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value as string }],
},
async execute(args: unknown) {
return [{ type: 'text', text: typeof args }]
return typeof args
},
})
// Missing the "required" path — but raw tools validate their own input, so
@@ -1387,7 +1744,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
})
it('attaches a positive-finite timeoutMs to the definition', () => {
const tool = defineTool({
const tool = defineContentToolFixture({
name: 'x', description: 'd', parameters: {}, timeoutMs: 30_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
@@ -1395,7 +1752,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
})
it('omits timeoutMs when not declared', () => {
const tool = defineTool({
const tool = defineContentToolFixture({
name: 'x', description: 'd', parameters: {},
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
@@ -1403,7 +1760,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
})
it('throws when timeoutMs is zero or negative', () => {
const make = (ms: number) => defineTool({
const make = (ms: number) => defineContentToolFixture({
name: 'x', description: 'd', parameters: {}, timeoutMs: ms,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
@@ -1412,7 +1769,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
})
it('throws when timeoutMs is non-finite', () => {
expect(() => defineTool({
expect(() => defineContentToolFixture({
name: 'x', description: 'd', parameters: {}, timeoutMs: Infinity,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})).toThrow('positive finite number')
@@ -1421,7 +1778,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
describe('defineTool presentation (presentCall / presentResult)', () => {
it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
const tool = defineTool({
const tool = defineContentToolFixture({
name: 'demo',
description: 'demo',
parameters: { path: { type: 'string', required: true }, n: { type: 'number' } },
@@ -1441,7 +1798,7 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
})
it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
const tool = defineTool({
const tool = defineContentToolFixture({
name: 'plain',
description: 'plain',
parameters: { x: { type: 'string', required: true } },
@@ -1452,7 +1809,7 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
})
it('presentCall/presentResult validate softly: malformed args return undefined, never throw (display runs on replay)', () => {
const tool = defineTool({
const tool = defineContentToolFixture({
name: 'demo',
description: 'demo',
parameters: { path: { type: 'string', required: true } },

View File

@@ -203,7 +203,8 @@ describe('dsh-acp-demo composition', () => {
name,
description: name,
parameters: {},
execute: async () => [],
output: { schema: { type: 'null' }, render: () => [] },
execute: async () => null,
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()

View File

@@ -494,7 +494,8 @@ describe('dsh-agent-spine-demo bundle', () => {
name,
description: name,
parameters: {},
execute: async () => [],
output: { schema: { type: 'null' }, render: () => [] },
execute: async () => null,
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()

View File

@@ -95,7 +95,13 @@ describe('dsh-cli-demo app composition', () => {
})
ctx.skills.register({ name: 'cli-skill', description: 'CLI skill', source: 'runtime', content: 'body' })
for (const name of ['alpha', 'zulu']) {
ctx.tools.register({ name, description: name, parameters: {}, execute: async () => [] })
ctx.tools.register({
name,
description: name,
parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
execute: async () => null,
})
}
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `cli-skill`: CLI...')
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual([

View File

@@ -115,7 +115,11 @@ async function harness(script: readonly ScriptEntry[]): Promise<Harness> {
name: 'echo',
description: 'Echo text.',
parameters: { text: { type: 'string', required: true } },
execute: async args => [{ type: 'text', text: `ECHO: ${(args as { text: string }).text}` }],
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value as string }],
},
execute: async args => `ECHO: ${(args as { text: string }).text}`,
})
const [agent] = ctx.agents.roots()
if (agent === undefined) throw new Error('test main agent missing')

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-tool-fs-search
The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `bash`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
```ts ignore-check
// Default deployment: a bash executor whose PATH includes rg, then the discovery tools.
@@ -39,7 +39,7 @@ Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`c
## Two budgets, two artifacts
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. The model-facing recovery artifact is different: when a search yields more logical results than the inline cap, the tool saves the COMPLETE formatted result through `ctx.spillStore.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the returned locator and retrieval hint. This is the first tool-owned spill call in the codebase — deliberate, because retention here is item-level: the generic `@deepseek-ai/dsh-spill-policy` only sees the final text on `tools/post-execute`, by which point a capped search has already omitted later paths/matches. A missing spill backend, a call with no session owner, or a `saveText()` failure keeps the inline page and reports that the complete result could not be saved — never an `isError`.
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps every acquired path in `{ paths }`; `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with a head page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
## Errors

View File

@@ -12,7 +12,6 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
@@ -20,6 +19,7 @@ import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
/**
* Default cap on paths retained inline by one `glob` call (the `globMaxResults`
@@ -117,6 +117,14 @@ export function formatGlobOutput(retained: RetainedItems<string>, spillRef: Spil
return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})`
}
/** Retain and format one canonical path list for the Native surface. */
function renderGlobPaths(paths: string[], maxResults: number, spillRef?: SpillRef): string {
if (paths.length === 0) return 'No files found'
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: maxResults })
for (const path of paths) retainer.push(path)
return formatGlobOutput(retainer.finish(), spillRef)
}
/**
* Pending-call presentation: a search card titled by the pattern (and root).
*
@@ -142,7 +150,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
text: 'Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.',
})
ctx.tools.register(defineTool({
const tool = defineTool({
name: 'glob',
description: 'Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, '
+ 'including hidden and ignored files (VCS metadata directories are excluded). '
@@ -152,28 +160,44 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
path: { type: 'string', description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.' },
},
timeoutMs: caps.timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
paths: { type: 'array', required: true, items: { type: 'string' } },
},
},
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }],
},
async execute(args, exec) {
const input = parseGlobArgs(args)
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes)
if (run.noMatches) return [{ type: 'text', text: 'No files found' }]
if (run.noMatches) return { paths: [] }
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: caps.maxResults })
const all: string[] = []
for (const line of run.stdout.split('\n')) {
if (line.length === 0) continue
const displayPath = toWorkdirRelative(line, run.workdir)
all.push(displayPath)
retainer.push(displayPath)
}
const retained = retainer.finish()
// The complete sorted list is the recovery artifact; save it only when
// the inline page omitted paths (an uncapped result needs no spill file).
const spillRef = retained.truncated
? await trySaveFormattedResult(ctx, exec, 'glob-results.txt', all.join('\n'))
: undefined
return [{ type: 'text', text: formatGlobOutput(retained, spillRef) }]
return { paths: all }
},
presentCall: presentGlobCall,
}))
})
ctx.tools.register(tool)
ctx.on('tools/post-execute', async (exec, result, next) => {
const decision = await next()
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { paths: string[] } | undefined
if (value === undefined) return decision
const paths = value.paths
if (paths.length <= caps.maxResults) return decision
const spillRef = await trySaveFormattedResult(ctx, exec, 'glob-results.txt', paths.join('\n'))
return {
kind: 'accept',
content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, spillRef) }],
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
}
})
}

View File

@@ -13,7 +13,6 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
@@ -21,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
/**
* Default cap on flat matches retained inline by one `grep` call (the
@@ -241,6 +241,20 @@ export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillRef: S
return `${header}\n\n${body}\n\n(${recovery})`
}
/** Apply the Native per-line preview budget without changing the canonical matches. */
function previewGrepMatches(matches: GrepMatch[], maxLineBytes: number): GrepMatch[] {
return matches.map(match => ({ ...match, line: previewLine(match.line, maxLineBytes) }))
}
/** Retain and format one canonical match list for the Native surface. */
function renderGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number, spillRef?: SpillRef): string {
if (matches.length === 0) return 'No matches found'
const previewed = previewGrepMatches(matches, maxLineBytes)
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
for (const match of previewed) retainer.push(match)
return formatGrepOutput(retainer.finish(), spillRef)
}
/**
* Pending-call presentation: a search card titled by the pattern (and target /
* include filter).
@@ -268,7 +282,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
text: 'Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.',
})
ctx.tools.register(defineTool({
const tool = defineTool({
name: 'grep',
description: 'Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. '
+ `Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. `
@@ -279,37 +293,70 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
include: { type: 'string', description: 'One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported.' },
},
timeoutMs: caps.timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
matches: {
type: 'array',
required: true,
items: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
lineNumber: { type: 'integer', required: true },
line: { type: 'string', required: true },
},
},
},
},
},
render: (_args, value) => [{
type: 'text',
text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes),
}],
},
async execute(args, exec) {
const input = parseGrepArgs(args)
const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes)
if (run.noMatches) return [{ type: 'text', text: 'No matches found' }]
if (run.noMatches) return { matches: [] }
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: caps.maxMatches })
const all: GrepMatch[] = []
for (const raw of parseGrepMatches(run.stdout)) {
const match: GrepMatch = {
path: toWorkdirRelative(raw.path, run.workdir),
lineNumber: raw.lineNumber,
line: previewLine(raw.line, caps.maxLineBytes),
line: raw.line,
}
all.push(match)
retainer.push(match)
}
const retained = retainer.finish()
// The spill file stores the FULL formatted match list (same grouped,
// per-line-previewed shape the model saw), so read offset/limit pages the
// same logical result; save only when the inline page omitted matches.
const spillRef = retained.truncated
? await trySaveFormattedResult(
ctx,
exec,
'grep-results.txt',
`Found ${all.length} ${matchNoun(all.length)}\n\n${formatGrepMatches(all)}`,
)
: undefined
return [{ type: 'text', text: formatGrepOutput(retained, spillRef) }]
return { matches: all }
},
presentCall: presentGrepCall,
}))
})
ctx.tools.register(tool)
ctx.on('tools/post-execute', async (exec, result, next) => {
const decision = await next()
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { matches: GrepMatch[] } | undefined
if (value === undefined) return decision
const matches = value.matches
if (matches.length <= caps.maxMatches) return decision
const spillRef = await trySaveFormattedResult(
ctx,
exec,
'grep-results.txt',
`Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewGrepMatches(matches, caps.maxLineBytes))}`,
)
return {
kind: 'accept',
content: [{
type: 'text',
text: renderGrepMatches(matches, caps.maxMatches, caps.maxLineBytes, spillRef),
}],
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
}
})
}

View File

@@ -0,0 +1,27 @@
/** Shared surface-only post-policy selection for search result spill. @module dsh-tool-fs-search/surface */
import type { Context } from 'cordis'
import type { JsonValue, PostToolDecision, ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
/**
* Return the accepted canonical value only when this tool still owns a direct
* successful surface call and no downstream policy replaced either projection.
* @param ctx - the tool plugin context used to resolve the live scoped owner.
* @param tool - the exact registered definition whose value may be projected.
* @param exec - the completed execution identity.
* @param result - the canonical result before post-policy decisions are applied.
* @param decision - the composed downstream post-policy decision.
* @returns the canonical value to project, or `undefined` when spill must defer.
*/
export function acceptedSurfaceValue(
ctx: Context,
tool: ToolDefinition,
exec: ToolExecution,
result: ToolExecutionResult,
decision: PostToolDecision,
): JsonValue | undefined {
if (decision.kind !== 'accept' || decision.content !== undefined || Object.hasOwn(decision, 'value')
|| exec.parent !== undefined || exec.name !== tool.name || result.isError
|| ctx.tools.get(exec.name, exec.agent) !== tool) return undefined
return result.value
}

View File

@@ -96,7 +96,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => {
const result = await call('glob', { pattern: '[' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } })
})
})
@@ -139,13 +139,13 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
it('classifies a real rg regex error as SEARCH_INVALID_PATTERN', async () => {
const result = await call('grep', { pattern: '(unclosed' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } })
})
it('classifies a missing target as SEARCH_FAILED', async () => {
const result = await call('grep', { pattern: 'x', path: 'no-such-dir' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
})
})
@@ -176,14 +176,14 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
signal: controller.signal,
})
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } })
})
it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => {
const gone = join(dir, 'deleted-session-dir')
const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('could not start')
})
})

View File

@@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { type ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
@@ -145,13 +145,19 @@ async function expectSetupRejects(options: SetupOptions, message: RegExp): Promi
const agent = (cwd?: string) => ({ session: { header: { id: 'session-1', ...cwd !== undefined ? { cwd } : {} } } })
let callCounter = 0
function call(ctx: Context, name: string, args: unknown, options: { agent?: object; signal?: AbortSignal } = {}) {
function call(
ctx: Context,
name: string,
args: unknown,
options: { agent?: object; signal?: AbortSignal; parent?: ToolExecutionToken } = {},
) {
return ctx.tools.execute({
callId: CallId(`call-${++callCounter}`),
name,
arguments: args,
...options.agent ? { agent: options.agent as never } : {},
...options.signal ? { signal: options.signal } : {},
...options.parent ? { parent: options.parent } : {},
})
}
@@ -310,7 +316,7 @@ describe('workdir derivation and signal forwarding', () => {
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
expect(bash.specs[0]?.signal).toBe(controller.signal)
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } })
expect(text(result)).toContain('aborted')
})
@@ -319,7 +325,7 @@ describe('workdir derivation and signal forwarding', () => {
bash.handler = () => runResult('', { timedOut: true, timeoutMs: 1234, exitCode: null, signal: 'SIGTERM' })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'SEARCH_ABORTED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_ABORTED' } })
expect(text(result)).toContain('timed out after 1234ms')
})
@@ -332,7 +338,7 @@ describe('workdir derivation and signal forwarding', () => {
bash.handler = () => { throw new Error('aborted before spawn') }
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } })
})
it('translates a run() rejection without an abort (unusable workdir) into SEARCH_FAILED', async () => {
@@ -340,7 +346,7 @@ describe('workdir derivation and signal forwarding', () => {
bash.handler = () => { throw new Error('spawn bash ENOENT') }
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('could not start')
})
})
@@ -361,7 +367,7 @@ describe('exit semantics and failure classification', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: regex parse error:\n (\nerror: unclosed group', truncated: false } })
const result = await call(ctx, 'grep', { pattern: '(' })
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } })
expect(text(result)).toContain('regex parse error')
})
@@ -369,14 +375,14 @@ describe('exit semantics and failure classification', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: error parsing glob \'[\': unclosed character class', truncated: false } })
const result = await call(ctx, 'glob', { pattern: '[' })
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } })
})
it('a missing rg binary classifies as SEARCH_FAILED naming ripgrep', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 127, stderr: { text: 'bash: line 1: rg: command not found', truncated: false } })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('requires ripgrep (rg)')
// The same classification holds from either evidence alone: the 127 exit
// with silent stderr, or a shell's command-not-found text on another exit.
@@ -390,7 +396,7 @@ describe('exit semantics and failure classification', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory', truncated: false } })
const result = await call(ctx, 'grep', { pattern: 'x', path: 'missing.dir' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('IO error')
})
@@ -398,7 +404,7 @@ describe('exit semantics and failure classification', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 3 })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('exit 3')
})
@@ -416,7 +422,7 @@ describe('exit semantics and failure classification', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: null, signal: 'SIGKILL' })
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('SIGKILL')
})
@@ -424,7 +430,7 @@ describe('exit semantics and failure classification', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: null, signal: null })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
})
})
@@ -442,7 +448,7 @@ describe('raw output acquisition', () => {
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
bash.handler = () => runResult('', { stdout: { text: 'x', truncated: true, spillPath: '/does/not/get-read' } })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } })
expect(text(result)).toContain('narrow pattern, path, or include')
})
@@ -453,7 +459,7 @@ describe('raw output acquisition', () => {
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
bash.handler = () => runResult(`${'x'.repeat(64)}\n`)
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } })
expect(text(result)).toContain('narrow pattern, path, or include')
})
@@ -461,7 +467,7 @@ describe('raw output acquisition', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true } })
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } })
})
})
@@ -470,6 +476,8 @@ describe('glob results', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n')
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: ['src/a.ts', '/elsewhere/b.ts', 'rel/c.ts'] })
expect(text(result)).toBe('src/a.ts\n/elsewhere/b.ts\nrel/c.ts')
})
@@ -489,9 +497,15 @@ describe('glob results', () => {
it('caps at globMaxResults and saves the FULL sorted list through spillStore', async () => {
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true })
ctx.on('tools/post-execute', async () => ({
kind: 'accept',
additionalContexts: [{ content: [{ type: 'text', text: 'glob context' }], source: { kind: 'plugin', plugin: 'test' } }],
}))
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)')
expect(spill?.saves).toHaveLength(1)
expect(spill?.saves[0]).toMatchObject({
@@ -501,6 +515,7 @@ describe('glob results', () => {
content: 'a.ts\nb.ts\nc.ts\nd.ts',
})
expect(spill?.saves[0]?.source.callId).toBeDefined()
expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'glob context' }])
})
it('does not create a spill file when the result fits inline', async () => {
@@ -511,6 +526,36 @@ describe('glob results', () => {
expect(spill?.saves).toHaveLength(0)
})
it('preserves a downstream canonical value replacement instead of spilling the old value', async () => {
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: true })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
value: { paths: ['replacement-a.ts', 'replacement-b.ts'] },
}))
bash.handler = () => runResult('old-a.ts\nold-b.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected glob replacement success')
expect(result.value).toEqual({ paths: ['replacement-a.ts', 'replacement-b.ts'] })
expect(text(result)).toContain('replacement-a.ts')
expect(text(result)).not.toContain('old-a.ts')
expect(spill?.saves).toHaveLength(0)
})
it('keeps the full nested Code value without creating a surface spill', async () => {
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true })
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, {
agent: agent('/w'),
parent: Symbol('run_code') as ToolExecutionToken,
})
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. The complete result could not be saved; narrow pattern or path to see more.)')
expect(spill?.saves).toHaveLength(0)
})
it.each([
['no spill backend loaded', { fail: false, spill: false, ownerless: false }],
['saveText fails', { fail: true, spill: true, ownerless: false }],
@@ -539,6 +584,14 @@ describe('grep results', () => {
].join('\n'))
const result = await call(ctx, 'grep', { pattern: 'const' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected grep success')
expect(result.value).toEqual({
matches: [
{ path: 'a.ts', lineNumber: 3, line: 'const x = 1' },
{ path: 'a.ts', lineNumber: 9, line: 'const y = 2' },
{ path: 'b.ts', lineNumber: 1, line: 'const z = 3' },
],
})
expect(text(result)).toBe('Found 3 matches\n\na.ts\nLine 3: const x = 1\nLine 9: const y = 2\n\nb.ts\nLine 1: const z = 3')
})
@@ -561,6 +614,8 @@ describe('grep results', () => {
// Use a multibyte straddle instead: 'aé' repeated — cut at 7 bytes: a(1)é(2)a(1)é(2)=6 +a(1)=7 → next é straddles: trimmed.
bash.handler = () => runResult(`${matchLine('a.txt', 1, 'aéaéaéaé')}\n`)
const result = await call(ctx, 'grep', { pattern: 'a' })
if (result.isError) throw new Error('expected grep success')
expect(result.value).toEqual({ matches: [{ path: 'a.txt', lineNumber: 1, line: 'aéaéaéaé' }] })
expect(text(result)).toContain('Line 1: aéaéa (line truncated)')
})
@@ -578,6 +633,10 @@ describe('grep results', () => {
it('caps at grepMaxMatches and spills the full formatted match list', async () => {
const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true })
ctx.on('tools/post-execute', async () => ({
kind: 'accept',
additionalContexts: [{ content: [{ type: 'text', text: 'grep context' }], source: { kind: 'plugin', plugin: 'test' } }],
}))
bash.handler = () => runResult([
matchLine('a.ts', 1, 'one'),
matchLine('a.ts', 2, 'two'),
@@ -585,12 +644,66 @@ describe('grep results', () => {
'',
].join('\n'))
const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected grep success')
expect(result.value).toEqual({
matches: [
{ path: 'a.ts', lineNumber: 1, line: 'one' },
{ path: 'a.ts', lineNumber: 2, line: 'two' },
{ path: 'b.ts', lineNumber: 3, line: 'three' },
],
})
expect(text(result)).toBe('Found 2 of 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\n(Full grep result stored at: /spill/grep-results.txt. Use the fake retrieval hint.)')
expect(spill?.saves[0]).toMatchObject({
source: { toolName: 'grep', label: 'result' },
suggestedName: 'grep-results.txt',
content: 'Found 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\nb.ts\nLine 3: three',
})
expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'grep context' }])
})
it('preserves a downstream canonical value replacement instead of spilling the old matches', async () => {
const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
value: {
matches: [
{ path: 'replacement.ts', lineNumber: 7, line: 'first' },
{ path: 'replacement.ts', lineNumber: 8, line: 'second' },
],
},
}))
bash.handler = () => runResult(`${matchLine('old.ts', 1, 'old')}\n`)
const result = await call(ctx, 'grep', { pattern: 'old' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected grep replacement success')
expect(result.value).toEqual({
matches: [
{ path: 'replacement.ts', lineNumber: 7, line: 'first' },
{ path: 'replacement.ts', lineNumber: 8, line: 'second' },
],
})
expect(text(result)).toContain('replacement.ts')
expect(text(result)).not.toContain('old.ts')
expect(spill?.saves).toHaveLength(0)
})
it('keeps every nested Code match in the value without creating a surface spill', async () => {
const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true })
bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('b.ts', 2, 'two')}\n`)
const result = await call(ctx, 'grep', { pattern: 'o' }, {
agent: agent('/w'),
parent: Symbol('run_code') as ToolExecutionToken,
})
if (result.isError) throw new Error('expected grep success')
expect(result.value).toEqual({
matches: [
{ path: 'a.ts', lineNumber: 1, line: 'one' },
{ path: 'b.ts', lineNumber: 2, line: 'two' },
],
})
expect(text(result)).toBe('Found 1 of 2 matches\n\na.ts\nLine 1: one\n\n(The complete result could not be saved; narrow pattern, path, or include to see more.)')
expect(spill?.saves).toHaveLength(0)
})
it('reports the unsaved remainder when capped with no spill backend', async () => {
@@ -633,7 +746,7 @@ describe('rg --json transport failures (SEARCH_FAILED)', () => {
bash.handler = () => runResult(`${line}\n`)
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } })
})
})

View File

@@ -32,6 +32,8 @@ All keys are optional; the defaults are the shipped read caps.
Field names are snake_case to match Claude Code and existing harness tool schemas.
Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`.
## The tool is the executor; policy is an event gate
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:

View File

@@ -8,10 +8,9 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
import { sessionResolveOptions } from './session-cwd.ts'
import type { FsSandboxSurface } from './sandbox.ts'
@@ -90,7 +89,26 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},
},
async execute(args: EditToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
before: { type: 'string', required: true },
after: { type: 'string', required: true },
},
},
render: (args, value) => [{
type: 'text',
text: formatEditOutput(value.path, args.replace_all ?? false),
}],
presentationMeta: (args, value) => ({
diffs: computeHunkDiffs(args.file_path, value.before, value.after)
.map(({ path, oldText, newText }) => ({ path, oldText, newText })),
}),
},
async execute(args: EditToolArgs, exec) {
const input = parseEditArgs(args)
// Resolve the per-call sandbox mode (escalation grant > session override
// > backend default) BEFORE anything executes.
@@ -115,11 +133,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// An edit necessarily changes content, so result metadata carries at least one applied hunk.
const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after)
return {
content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }],
meta: { diffs },
path: target.displayPath,
before: outcome.before,
after: outcome.after,
}
},
// Pure display: a diff card of the literal replacement (old_string → new_string), derived

View File

@@ -77,6 +77,7 @@ function lineByteSize(line: string, currentLineCount: number): number {
function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void {
acc.totalLines += 1
if (acc.done) return
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
const text = truncateLine(rawLine, request.maxLineLength)
@@ -137,7 +138,6 @@ export async function buildWindow(
appendToLineBuffer(chunk.slice(startPos, newlinePos))
flushLine()
startPos = newlinePos + 1
if (acc.done) return finish(acc, request, displayPath)
}
appendToLineBuffer(chunk.slice(startPos))
}

View File

@@ -7,12 +7,10 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { FsError } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { buildWindow, formatReadOutput } from './read-render.ts'
import type { FileReadOutcome } from './read-render.ts'
import { sessionResolveOptions } from './session-cwd.ts'
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
@@ -84,9 +82,46 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
offset: { type: 'integer', required: true },
lines: {
type: 'array',
required: true,
items: {
type: 'object',
additionalProperties: false,
properties: {
number: { type: 'integer', required: true },
text: { type: 'string', required: true },
},
},
},
totalLines: { type: 'integer', required: true },
},
},
render: (args, value) => {
const input = parseReadArgs(args, caps.limit)
const endLine = value.lines.at(-1)?.number ?? Math.max(0, value.offset - 1)
const truncatedByBytes = value.lines.length < input.limit && endLine < value.totalLines
return [{
type: 'text',
text: formatReadOutput(value.path, {
offset: value.offset,
lines: value.lines,
totalLines: value.totalLines,
...truncatedByBytes ? { truncatedByBytes: true } : {},
}),
}]
},
},
// Observation races fail closed because guarded mutations re-check the version in-lock.
isConcurrencySafe: () => true,
async execute(args, exec): Promise<ContentBlock[]> {
async execute(args, exec) {
const input = parseReadArgs(args, caps.limit)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
@@ -107,17 +142,17 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
target.displayPath,
)
const outcome: FileReadOutcome = {
const outcome = {
path: target.displayPath,
offset: input.offset,
lines: window.lines,
totalLines: window.totalLines,
...window.truncatedByBytes ? { truncatedByBytes: true } : {},
}
// Record the observed version (a no-op when no policy plugin listens). The
// read already succeeded; an fs/observed listener is contractually a
// synchronous, side-effect-only recorder.
ctx.emit('fs/observed', target, info.version, exec)
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
return outcome
},
// Pure display: a generic card titled by the file with the read window appended (`Read
// foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the

View File

@@ -8,11 +8,10 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
import { sessionResolveOptions } from './session-cwd.ts'
import type { FsSandboxSurface } from './sandbox.ts'
@@ -33,7 +32,7 @@ export function parseWriteArgs(args: { file_path: string; content: string }): {
* @param outcome - the write outcome; its `operation` selects the Created/Updated wording.
* @returns the model-facing confirmation envelope (no file content is echoed back).
*/
export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string {
export function formatWriteOutput(displayPath: string, outcome: Pick<FsWriteOutcome, 'operation'>): string {
const verb = outcome.operation === 'create' ? 'Created' : 'Updated'
return `<path>${displayPath}</path>
<type>file</type>
@@ -74,7 +73,32 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},
},
async execute(args: WriteToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
operation: { type: 'string', required: true, enum: ['create', 'update'] },
before: {
required: true,
oneOf: [
{ type: 'string' },
{ type: 'null' },
],
},
after: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: formatWriteOutput(value.path, value) }],
presentationMeta: (args, value) => ({
diffs: value.before === null
? []
: computeHunkDiffs(args.file_path, value.before, value.after)
.map(({ path, oldText, newText }) => ({ path, oldText, newText })),
}),
},
async execute(args: WriteToolArgs, exec) {
const input = parseWriteArgs(args)
// Resolve the per-call sandbox mode (escalation grant > session override
// > backend default) BEFORE anything executes; an escalating call
@@ -94,12 +118,11 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// Overwrites carry applied hunks. Creates have no prior text, so result presentation uses
// the args-derived whole-file diff instead.
const diffs = outcome.before !== null ? computeHunkDiffs(input.filePath, outcome.before, outcome.after) : []
return {
content: [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }],
...diffs.length > 0 ? { meta: { diffs } } : {},
path: target.displayPath,
operation: outcome.operation,
before: outcome.before,
after: outcome.after,
}
},
// Pure display: a diff card (an editor renders write as a new-file / full- replace diff).

View File

@@ -67,7 +67,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
await writeFile(join(dir, 'a.txt'), 'original')
const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
})
@@ -85,7 +85,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
})
})
@@ -102,7 +102,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02]))
const result = await call('read', { file_path: 'bin' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_TEXT' } })
})
it('paginates a multi-line file with offset/limit', async () => {
@@ -127,7 +127,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
})
@@ -151,7 +151,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world'
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
})
it('rejects an ambiguous match without replace_all', async () => {
@@ -159,7 +159,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
await call('read', { file_path: 'a.txt' })
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
expect(result.error).toMatchObject({ info: { code: 'FS_AMBIGUOUS_EDIT' } })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a')
})
@@ -187,7 +187,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
// The model-facing edit still rejects: the read did not emit fs/observed.
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
})
})
@@ -260,14 +260,14 @@ describe('bare provider (no dsh-fs-policy)', () => {
it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => {
const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
})
it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
expect(result.error).toMatchObject({ info: { code: 'FS_EDIT_NOT_FOUND' } })
})
it('neither write nor edit stats in the tool on the bare path', async () => {
@@ -350,11 +350,11 @@ describe('signal, concurrency, and the fs/observed contract', () => {
await writeFile(join(dir, 'a.txt'), 'hello')
const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' })
expect(read.isError).toBe(true)
expect(read.error).toMatchObject({ code: 'FS_ABORTED' })
expect(read.error).toMatchObject({ info: { code: 'FS_ABORTED' } })
const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' })
expect(write.isError).toBe(true)
expect(write.error).toMatchObject({ code: 'FS_ABORTED' })
expect(write.error).toMatchObject({ info: { code: 'FS_ABORTED' } })
await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
// Read first (un-aborted, SAME session owner) so the edit clears the
@@ -363,7 +363,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' })
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ code: 'FS_ABORTED' })
expect(edit.error).toMatchObject({ info: { code: 'FS_ABORTED' } })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged
})
@@ -378,7 +378,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
])
const errors = [one, two].filter(r => r.isError)
expect(errors).toHaveLength(1)
expect(errors[0]?.error).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(errors[0]?.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// The world is consistent: exactly one edit landed.
const onDisk = await readFile(join(dir, 'a.txt'), 'utf8')
expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true)
@@ -407,7 +407,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
new_string: 'edited',
})
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('newer current content\n')
})

View File

@@ -161,6 +161,13 @@ describe('read tool', () => {
fs.files.set('key:a.txt', 'hello\nworld')
const result = await call(ctx, 'read', { file_path: 'a.txt' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected read success')
expect(result.value).toEqual({
path: '/abs/a.txt',
offset: 1,
lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }],
totalLines: 2,
})
expect(text(result)).toBe(`<path>/abs/a.txt</path>
<type>file</type>
<content>
@@ -171,6 +178,15 @@ describe('read tool', () => {
</content>`)
})
it('returns an explicit empty canonical line window for an empty file', async () => {
const { ctx, fs } = await setup()
fs.files.set('key:empty.txt', '')
const result = await call(ctx, 'read', { file_path: 'empty.txt' })
if (result.isError) throw new Error('expected empty read success')
expect(result.value).toEqual({ path: '/abs/empty.txt', offset: 1, lines: [], totalLines: 0 })
expect(text(result)).toContain('(End of file - total 0 lines)')
})
it('rejects a non-positive offset via arg validation', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 })
@@ -226,7 +242,7 @@ describe('read tool', () => {
const { ctx } = await setup()
const result = await call(ctx, 'read', { file_path: 'missing.txt' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
})
it('rejects a non-regular target', async () => {
@@ -235,7 +251,7 @@ describe('read tool', () => {
fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' })
const result = await call(ctx, 'read', { file_path: 'd' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
})
it('streams a large file (size at/above the cap) instead of reading whole', async () => {
@@ -301,6 +317,8 @@ describe('write tool', () => {
const { ctx, fs } = await setup()
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: { header: {} } })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected write success')
expect(result.value).toEqual({ path: '/abs/a.txt', operation: 'create', before: null, after: 'hi' })
expect(text(result)).toContain('Created file')
expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }])
})
@@ -317,7 +335,7 @@ describe('write tool', () => {
fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION')
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' })
expect(result.error).toMatchObject({ info: { name: 'FsError', code: 'FS_STALE_VERSION' } })
})
})
@@ -328,6 +346,8 @@ describe('edit tool', () => {
fs.files.set('key:a.txt', 'a')
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session })
if (result.isError) throw new Error('expected edit success')
expect(result.value).toEqual({ path: '/abs/a.txt', before: 'a', after: 'b' })
expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.')
})
@@ -366,7 +386,7 @@ describe('edit tool', () => {
fs.files.set('key:a.txt', 'hello')
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: { header: {} } })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
})
})
@@ -464,27 +484,27 @@ describe('result-time contextual diff (meta + presentResult)', () => {
expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] })
})
it('write CREATE: no before-version → no meta, but presentResult still renders a whole-file diff card', async () => {
// A create has no prior content (no `meta`), yet the completed card must be a `diff` — an
it('write CREATE: an empty applied-diff projection still falls back to the whole-file diff card', async () => {
// A create has no prior content, yet the completed card must be a `diff` — an
// ACP tool_call_update.content REPLACES the call's content, so a non-diff result would
// clobber the pending new-file diff.
const { ctx } = await setup()
const session = { header: {} }
const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session })
expect(result.isError).toBe(false)
expect(result.meta).toBeUndefined()
expect(result.meta).toEqual({ diffs: [] })
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result)
expect(view).toEqual({ card: 'diff', title: 'Write new.txt', diffs: [{ path: 'new.txt', oldText: null, newText: 'fresh\n' }] })
})
it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta, presentResult falls back to a whole-file diff', async () => {
it('write OVERWRITE with identical content: an empty applied-diff projection falls back to a whole-file diff', async () => {
const { ctx, fs } = await setup()
const session = { header: {} }
fs.files.set('key:a.txt', 'same\n')
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session })
expect(result.isError).toBe(false)
expect(result.meta).toBeUndefined()
expect(result.meta).toEqual({ diffs: [] })
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'same\n' }, result)
expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'same\n' }] })
})

View File

@@ -10,6 +10,8 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations.
All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON.
An autonomous goal round that successfully reports `complete` or `blocked` contributes the existing terminal `agent/turn-stop` decision for that physical turn. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop.
## Authority

View File

@@ -54,6 +54,62 @@ const GET_DESCRIPTION =
+ 'continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. '
+ 'Call this before updating a goal.'
/** Canonical goal-tool output, matching the existing compact Native JSON. */
type GoalToolValue =
| { goal: null }
| {
goal: {
id: string
revision: number
objective: string
phase: GoalView['phase']
roundsStarted: number
maxGoalRounds: number
blockedReason?: { code: string; message: string }
}
activation: GoalView['activation']
}
const GOAL_VALUE_SCHEMA = {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
goal: { type: 'null', required: true },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
goal: {
type: 'object',
additionalProperties: false,
required: true,
properties: {
id: { type: 'string', required: true },
revision: { type: 'integer', required: true },
objective: { type: 'string', required: true },
phase: { type: 'string', required: true, enum: ['active', 'paused', 'blocked', 'complete'] },
roundsStarted: { type: 'integer', required: true },
maxGoalRounds: { type: 'integer', required: true },
blockedReason: {
type: 'object',
additionalProperties: false,
properties: {
code: { type: 'string', required: true },
message: { type: 'string', required: true },
},
},
},
},
activation: { type: 'string', required: true, enum: ['armed', 'disarmed'] },
},
},
],
} as const
/** Render policy guidance with its deployment-selected blocked threshold. */
function guidance(blockedAfter: number): string {
return 'Use goal tools for one long-running completion objective in the current session. '
@@ -89,9 +145,9 @@ function goalRef(goalId: string, revision: number): GoalRef {
}
/** Stable compact model result; activation is an observation, not replay state. */
function renderGoal(goal: GoalView | undefined): string {
if (goal === undefined) return JSON.stringify({ goal: null })
return JSON.stringify({
function goalValue(goal: GoalView | undefined): GoalToolValue {
if (goal === undefined) return { goal: null }
return {
goal: {
id: goal.id,
revision: goal.revision,
@@ -99,10 +155,18 @@ function renderGoal(goal: GoalView | undefined): string {
phase: goal.phase,
roundsStarted: goal.roundsStarted,
maxGoalRounds: goal.maxGoalRounds,
...goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason },
...goal.blockedReason === undefined ? {} : {
blockedReason: { code: goal.blockedReason.code, message: goal.blockedReason.message },
},
},
activation: goal.activation,
})
}
}
/** Reusable canonical output declaration for all three goal controls. */
const GOAL_OUTPUT = {
schema: GOAL_VALUE_SCHEMA,
render: (_args: unknown, value: GoalToolValue) => [{ type: 'text' as const, text: JSON.stringify(value) }],
}
/** Generic, args-only pending presentation shared by the goal tools. */
@@ -144,12 +208,10 @@ export function apply(ctx: Context, config: Config): void {
name: 'get_goal',
description: GET_DESCRIPTION,
parameters: {},
output: GOAL_OUTPUT,
execute(_args, exec) {
const execution = goalToolExecution(ctx, exec)
return Promise.resolve([{
type: 'text',
text: renderGoal(ctx.goals.get(execution.agent)),
}])
return Promise.resolve(goalValue(ctx.goals.get(execution.agent)))
},
presentCall: () => present('Read current goal', 'read'),
}))
@@ -168,6 +230,7 @@ export function apply(ctx: Context, config: Config): void {
description: 'Optional positive safe-integer limit on automatic continuation rounds.',
},
},
output: GOAL_OUTPUT,
execute(args, exec) {
const execution = goalToolExecution(ctx, exec)
requireDirectHuman(ctx, execution)
@@ -176,7 +239,7 @@ export function apply(ctx: Context, config: Config): void {
...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },
})
observeMutation(terminalTurns, execution, false)
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
return Promise.resolve(goalValue(goal))
},
presentCall: args => present('Create goal', 'other', args.objective),
}))
@@ -203,6 +266,7 @@ export function apply(ctx: Context, config: Config): void {
description: 'Concrete blocking condition; required only with action blocked.',
},
},
output: GOAL_OUTPUT,
execute(args, exec) {
const execution = goalToolExecution(ctx, exec)
const ref = goalRef(args.goal_id, args.revision)
@@ -217,10 +281,7 @@ export function apply(ctx: Context, config: Config): void {
}
const goal = ctx.goals.edit(execution.agent, ref, replacements)
observeMutation(terminalTurns, execution, false)
return Promise.resolve([{
type: 'text',
text: renderGoal(goal),
}])
return Promise.resolve(goalValue(goal))
}
if (args.action === 'pause' || args.action === 'resume') {
requireDirectHuman(ctx, execution)
@@ -234,7 +295,7 @@ export function apply(ctx: Context, config: Config): void {
? ctx.goals.pause(execution.agent, ref)
: ctx.goals.resume(execution.agent, ref)
observeMutation(terminalTurns, execution, false)
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
return Promise.resolve(goalValue(goal))
}
const authority = completionAuthority(ctx, execution)
if (args.objective !== undefined || args.max_goal_rounds !== undefined) {
@@ -265,7 +326,7 @@ export function apply(ctx: Context, config: Config): void {
message: args.blocked_reason as string,
})
observeMutation(terminalTurns, execution, authority.kind === 'goal-round')
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
return Promise.resolve(goalValue(goal))
},
presentCall: args => present(
`${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`,

View File

@@ -95,9 +95,12 @@ async function execute(
/** Parse the compact JSON returned by a successful goal tool. */
function resultJson(result: ToolExecutionResult): Record<string, unknown> {
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected goal tool success')
const block = result.content[0]
if (block?.type !== 'text') throw new Error('expected text tool result')
return JSON.parse(block.text) as Record<string, unknown>
const parsed = JSON.parse(block.text) as Record<string, unknown>
expect(result.value).toEqual(parsed)
return parsed
}
/** Read the returned goal sub-object. */
@@ -193,7 +196,7 @@ describe('goal tool execution authority', () => {
it('rejects agentless, driverless, non-human, and live-child creation', async () => {
const { ctx, root } = await harness()
const agentless = await execute(ctx, 'get_goal', {})
expect(agentless.error?.code).toBe('GOAL_TOOL_AGENT_REQUIRED')
expect(agentless.error?.info?.code).toBe('GOAL_TOOL_AGENT_REQUIRED')
openTurn(root, { kind: 'user' })
const driverless = await ctx.tools.execute({
@@ -202,12 +205,12 @@ describe('goal tool execution authority', () => {
arguments: {},
agent: root.agent,
})
expect(driverless.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
expect(driverless.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
closeTurn(root, 1)
openTurn(root, { kind: 'plugin', plugin: 'test' })
const nonHuman = await execute(ctx, 'create_goal', { objective: 'forged' }, root.agent)
expect(nonHuman.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
expect(nonHuman.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
closeTurn(root, 2)
const child = stubAgent('goal-tool-child')
@@ -215,7 +218,7 @@ describe('goal tool execution authority', () => {
ctx.agents.announce(child.agent)
openTurn(child, { kind: 'user' })
const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent)
expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
expect(childResult.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
})
it('rejects stale agent objects and agents outside running status through the executor', async () => {
@@ -223,11 +226,11 @@ describe('goal tool execution authority', () => {
openTurn(root, { kind: 'user' })
const stale = { ...root.agent }
const staleResult = await execute(ctx, 'get_goal', {}, stale, stale)
expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
expect(staleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
root.setStatus('idle')
const idleResult = await execute(ctx, 'get_goal', {}, root.agent)
expect(idleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
expect(idleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
})
it('treats a fork resumed as a runtime root as direct-human authority', async () => {
@@ -257,12 +260,12 @@ describe('goal tool execution authority', () => {
it('rejects calls before a turn and after its end boundary', async () => {
const { ctx, root } = await harness()
const before = await execute(ctx, 'get_goal', {}, root.agent)
expect(before.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
expect(before.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
const turn = openTurn(root, { kind: 'user' })
closeTurn(root, turn)
const after = await execute(ctx, 'get_goal', {}, root.agent)
expect(after.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
expect(after.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
})
it('rejects terminal reporting without human input or a current goal round', async () => {
@@ -271,11 +274,11 @@ describe('goal tool execution authority', () => {
const result = await execute(ctx, 'update_goal', {
goal_id: 'goal-missing', revision: 1, action: 'complete',
}, root.agent)
expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
expect(result.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
const malformed = await execute(ctx, 'update_goal', {
goal_id: 'goal-missing', revision: 1, action: 'pause', objective: 'probe',
}, root.agent)
expect(malformed.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
expect(malformed.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
})
it('accepts direct human steering in a goal-sourced root turn', async () => {
@@ -303,7 +306,7 @@ describe('goal tool execution authority', () => {
ctx.agents.register(other.agent)
openTurn(other, { kind: 'user' })
const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent)
expect(result.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
expect(result.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
})
})
@@ -374,7 +377,7 @@ describe('goal tool state transitions', () => {
const { ctx, root } = await harness()
openTurn(root, { kind: 'user' })
const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent)
expect(invalidCreate.error?.code).toBe('GOAL_INVALID_OBJECTIVE')
expect(invalidCreate.error?.info?.code).toBe('GOAL_INVALID_OBJECTIVE')
const created = ctx.goals.create(root.agent, { objective: 'valid' })
const replacement = await execute(ctx, 'update_goal', {
goal_id: created.id,
@@ -382,26 +385,26 @@ describe('goal tool state transitions', () => {
action: 'pause',
objective: 'not valid for pause',
}, root.agent)
expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(replacement.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const terminalUpdate = await execute(ctx, 'update_goal', {
goal_id: created.id,
revision: created.revision,
action: 'complete',
max_goal_rounds: 2,
}, root.agent)
expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(terminalUpdate.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const blockedWithoutReason = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'blocked',
}, root.agent)
expect(blockedWithoutReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(blockedWithoutReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const blockedWithEmptyReason = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ',
}, root.agent)
expect(blockedWithEmptyReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(blockedWithEmptyReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const completeWithReason = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.',
}, root.agent)
expect(completeWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(completeWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const editWithReason = await execute(ctx, 'update_goal', {
goal_id: created.id,
revision: created.revision,
@@ -409,11 +412,11 @@ describe('goal tool state transitions', () => {
objective: 'still valid',
blocked_reason: 'Not valid for edit.',
}, root.agent)
expect(editWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(editWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const malformedRef = await execute(ctx, 'update_goal', {
goal_id: '', revision: 0, action: 'edit', objective: 'x',
}, root.agent)
expect(malformedRef.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
})
it('allows exact goal rounds to complete but not edit or pause', async () => {
@@ -425,7 +428,7 @@ describe('goal tool state transitions', () => {
const edit = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'edit', objective: 'forbidden',
}, root.agent)
expect(edit.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
expect(edit.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
const complete = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'complete',
}, root.agent)
@@ -447,7 +450,7 @@ describe('goal tool state transitions', () => {
action: 'blocked',
blocked_reason: 'The required credential is still unavailable.',
}, root.agent)
expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD')
expect(result.error?.info?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD')
closeTurn(root, turn)
}
openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 })

View File

@@ -213,8 +213,7 @@ export function apply(ctx: Context, config: Config): void {
return { kind: 'block', feedback: downstream.feedback, additionalContexts: prependContext(reminder, downstream.additionalContexts) }
}
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
...downstream,
additionalContexts: prependContext(reminder, downstream.additionalContexts),
}
})

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -24,8 +24,8 @@ async function harness(config: Config = {}): Promise<Context> {
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(RepeatToolGuard, config)
ctx.tools.register(defineTool({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineTool({ name: 'other', description: 'o', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'other', description: 'o', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
return ctx
}
@@ -332,11 +332,11 @@ describe('fold onto the downstream decision', () => {
expect(results[1]!.data.content).toEqual([{ type: 'text', text: 'nope' }])
})
it('preserves a downstream accept content replacement while folding', async () => {
it('preserves a downstream canonical value replacement while folding', async () => {
const ctx = await harness({ thresholds: [2] })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
content: [{ type: 'text' as const, text: 'replaced' }],
value: [{ type: 'text' as const, text: 'replaced' }],
}))
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),

View File

@@ -253,8 +253,7 @@ export function apply(ctx: Context, config: Config): void {
return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) }
}
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
...downstream,
additionalContexts: prependContext(context, downstream.additionalContexts),
}
})

View File

@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { Context, type Fiber } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -138,7 +138,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'use danger' }])
await waitForIdle(ctx, agent)
@@ -161,7 +161,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'safe', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'use safe' }])
await waitForIdle(ctx, agent)
@@ -183,7 +183,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -204,7 +204,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -228,7 +228,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)

View File

@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { Context } from 'cordis'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -65,7 +65,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -95,7 +95,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d })
ctx.logger.warn = warn as never
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -111,7 +111,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter)
ctx.logger.warn = warn as never
let sawArgs: unknown
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -142,7 +142,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const ctx = await harness(path, new MockAdapter([]))
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
// Call execute() directly with NO agent — the bridge's no-agent/no-turn path.
const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} })
@@ -157,7 +157,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -182,7 +182,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -262,7 +262,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -276,7 +276,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -320,7 +320,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -335,7 +335,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -375,7 +375,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -390,7 +390,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -409,7 +409,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -426,7 +426,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -446,7 +446,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -529,16 +529,16 @@ export function defineCoverageCases(group: CoverageGroup): void {
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => {
it('folds the bridge PostToolUse context onto a downstream canonical value replacement', async () => {
// The bridge hook adds context; a later post-execute listener accepts with a
// content rewrite. Both the rewrite and the bridge context survive.
// canonical replacement. Both the replacement and the bridge context survive.
const d = dir()
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n')
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -553,7 +553,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
additionalContexts: [{
@@ -583,7 +583,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
@@ -608,7 +608,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
// yields a HookOutput with exitCode undefined → the `exitCode` spread false arm.
const bash = ctx.bash
bash.run = (() => Promise.reject(new Error('executor down')))
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -656,7 +656,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })

View File

@@ -226,8 +226,7 @@ export function apply(ctx: Context, config: Config): void {
return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) }
}
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
...downstream,
additionalContexts: prependContext(context, downstream.additionalContexts),
}
})

View File

@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -75,7 +75,7 @@ describe('hooks-codex bridge', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run ls' }])
await waitForIdle(ctx, agent)

View File

@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { Context } from 'cordis'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -61,7 +61,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -144,13 +144,13 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
})
if (selected.has('post-tool')) describe('hooks-codex coverage — post-tool and session context mapping', () => {
it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => {
it('folds the bridge PostToolUse context onto a downstream canonical value replacement', async () => {
const d = dir()
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
@@ -163,7 +163,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
additionalContexts: [{
@@ -188,7 +188,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
@@ -215,7 +215,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'p.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const r = events(agent).find(e => e.type === 'tool/result')
@@ -228,7 +228,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"post-ctx"}}\'\n') }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
@@ -242,7 +242,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', {}), textResponse('done')]) // no command arg
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true) // clean-exit hook allows; commandOf returned ''
@@ -253,7 +253,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
@@ -266,7 +266,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
@@ -289,7 +289,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 })
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
@@ -325,7 +325,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true)
@@ -366,7 +366,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true)
@@ -379,7 +379,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded
@@ -395,7 +395,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
@@ -408,7 +408,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const r = events(agent).find(e => e.type === 'tool/result')
@@ -420,7 +420,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'bc.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"ctx too"}}\'\n') }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const r = events(agent).find(e => e.type === 'tool/result')
@@ -437,7 +437,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } }
@@ -449,7 +449,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([]))
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
expect(ran).toBe(false) // denied
@@ -460,7 +460,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const d = dir()
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"x"}}\'\n') }] }] })
const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([]))
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
expect(result.isError).toBeFalsy()
@@ -473,7 +473,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.bash.run = (() => Promise.reject(new Error('executor down')))
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
@@ -570,7 +570,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } }
@@ -586,7 +586,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(false) // the matcher fired → the hook denied the tool
@@ -620,7 +620,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' })
ctx.llm.registerAdapter(['mock'], adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])

View File

@@ -6,7 +6,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -166,7 +166,7 @@ describe('bounded transient retry policy', () => {
])
;({ ctx: context } = await harness(adapter))
let toolExecutions = 0
context.tools.register(defineTool({
context.tools.register(defineContentToolFixture({
name: 'danger',
description: 'must not run for a failed provider attempt',
parameters: {},

View File

@@ -56,8 +56,9 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
- On connect: `listTools()` → registers each tool via `ctx.tools.register()` under its public name.
- Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered.
- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support — the public name is never sent to the server.
- Image content in results is discarded with a placeholder (the harness has no image block type).
- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server.
- Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`.
- Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders.
- On disconnect/crash: all tools are unregistered; no auto-reconnect.
## Services consumed
@@ -86,7 +87,7 @@ Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync
#### What the model sees
The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained text result; image, audio, resource, and unsupported blocks become short placeholders, and MCP `isError` results follow the registry's model-visible error path.
The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained Native text result; image, audio, resource, and unsupported blocks become short placeholders there. Their full JSON blocks and optional structured content remain in the execution-local canonical value, and MCP `isError` rejects the call through the registry's error path.
#### Token effect
@@ -101,4 +102,5 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Initial discovery is asynchronous** — plugin load does not wait for connection and `listTools()`, so a turn started immediately after boot or HMR can assemble before the MCP tools are registered.
- **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred.
- **Crash recovery is manual** — transport closure unregisters the server's tools, but reconnect requires an HMR reload or harness restart.
- **Non-text results are lossy** — image, audio, and resource payloads are replaced with placeholders, and a structured-only result has no model-visible structured representation.
- **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred.
- **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset.

View File

@@ -22,6 +22,8 @@ import { syncTools } from './tools.ts'
// Side-effect type import: declaration-merges `ctx.tools` onto Context.
import type {} from '@deepseek-ai/dsh-tools'
export type { McpResult } from './tools.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'mcp-client'

View File

@@ -16,6 +16,8 @@ import { createHash } from 'node:crypto'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import type { Context } from 'cordis'
import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools'
import type { JsonSchemaNode, JsonValue } from '@deepseek-ai/dsh-tools'
/** Resolved options relevant to tool bridging. */
export interface ToolBridgeOptions {
@@ -26,6 +28,12 @@ export interface ToolBridgeOptions {
/** State for one sync generation: the current set of disposers keyed by public name. */
export type ToolDisposers = Map<string, () => void>
/** Canonical MCP result exposed to Code Mode without discarding protocol blocks. */
export type McpResult<Structured extends JsonValue = JsonValue> = {
content: JsonValue[]
structuredContent?: Structured
}
/**
* DeepSeek function-name contract: at most 64 characters. Wire-protocol
* constant, not configuration.
@@ -105,6 +113,7 @@ export async function syncTools(
name: publicName,
description: tool.description ?? '',
parameters: tool.inputSchema,
output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)),
execute: createExecutor(client, tool.name, opts),
})
}
@@ -141,6 +150,36 @@ interface McpContentBlock {
mimeType?: string
}
/** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */
function supportedOutputSchema(candidate: unknown): JsonSchemaNode | undefined {
if (candidate === undefined) return undefined
try {
assertSupportedJsonSchema(candidate)
return candidate
} catch {
return undefined
}
}
/** Build the canonical result schema and existing Native text projection. */
function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefined): ToolDefinition['output'] {
return {
schema: {
type: 'object',
properties: {
content: { type: 'array', items: {} },
structuredContent: structuredSchema ?? {},
},
required: ['content'],
additionalProperties: false,
},
render(_args, value) {
const result = value as unknown as McpResult
return [{ type: 'text', text: extractText(result.content, rawName) }]
},
}
}
/**
* Create an execute function for one MCP tool. The executor closes over the
* raw MCP tool name and calls `client.callTool` with it (never the public
@@ -172,18 +211,24 @@ function createExecutor(
// The SDK may return a legacy `toolResult` shape; normalize to content array.
if (!('content' in result) || !Array.isArray(result.content)) {
const text = 'toolResult' in result
const rendered: unknown = 'toolResult' in result
? JSON.stringify(result.toolResult)
: '(no output)'
return [{ type: 'text' as const, text }]
const text = typeof rendered === 'string' ? rendered : '(no output)'
if ('isError' in result && result.isError === true) throw new Error(text)
return {
content: [{ type: 'text', text }],
...'structuredContent' in result && result.structuredContent !== undefined
? { structuredContent: result.structuredContent as JsonValue }
: {},
}
}
// Trust boundary: the SDK's return type erases to `any[]` due to the
// union of CallToolResult | CompatibilityCallToolResult. We process each
// element defensively in extractText (reading only .type/.text/.mimeType
// with optional fallbacks).
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const content: McpContentBlock[] = result.content
const content = result.content as unknown as JsonValue[]
const text = extractText(content, rawName)
// MCP isError → throw so ToolRegistry produces an isError result for the model.
@@ -191,7 +236,12 @@ function createExecutor(
throw new Error(text)
}
return [{ type: 'text', text }]
return {
content,
...'structuredContent' in result && result.structuredContent !== undefined
? { structuredContent: result.structuredContent as JsonValue }
: {},
}
}
}
@@ -203,10 +253,15 @@ function createExecutor(
* Defensive: fields that the MCP spec declares required (mimeType, text) are
* guarded with fallbacks because this is a network trust boundary.
*/
function extractText(mcpContent: McpContentBlock[], toolName: string): string {
function extractText(mcpContent: JsonValue[], toolName: string): string {
const parts: string[] = []
for (const block of mcpContent) {
for (const value of mcpContent) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
parts.push('[unsupported content type: unknown]')
continue
}
const block = value as unknown as McpContentBlock
switch (block.type) {
case 'text':
if (block.text !== undefined) parts.push(block.text)

View File

@@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { type JsonValue } from '@deepseek-ai/dsh-tools'
import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts'
import type { Config } from '@deepseek-ai/dsh-mcp-client'
@@ -13,10 +13,12 @@ interface MockTool {
name: string
description?: string
inputSchema: Record<string, unknown>
outputSchema?: Record<string, unknown>
}
interface MockCallResult {
content: Array<{ type: string; text?: string; mimeType?: string }>
content: JsonValue[]
structuredContent?: JsonValue
isError?: boolean
}
@@ -114,7 +116,8 @@ describe('syncTools', () => {
name: 'search',
description: 'Native search',
parameters: { type: 'object' },
execute: async () => [{ type: 'text', text: 'native' }],
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value as string }] },
execute: async () => 'native',
})
const client = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
@@ -156,7 +159,8 @@ describe('syncTools', () => {
name: 'mcp__srv__taken',
description: 'Squatter',
parameters: { type: 'object' },
execute: async () => [{ type: 'text', text: 'squatter' }],
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value as string }] },
execute: async () => 'squatter',
})
const client = createMockClient([
{ name: 'free', inputSchema: { type: 'object' } },
@@ -221,6 +225,8 @@ describe('tool execution', () => {
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'hello world' }])
if (result.isError) throw new Error('expected MCP success')
expect(result.value).toEqual({ content: [{ type: 'text', text: 'hello world' }] })
// The wire sees the raw MCP name, never the public name.
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'echo', arguments: { msg: 'hi' } },
@@ -259,16 +265,85 @@ describe('tool execution', () => {
expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }])
})
it('discards image content with placeholder', async () => {
it('preserves full JSON MCP blocks while Native rendering uses placeholders', async () => {
const blocks = [
{ type: 'text', text: 'before' },
{ type: 'image', mimeType: 'image/png', data: 'base64-data', annotations: { audience: ['assistant'] } },
] satisfies JsonValue[]
const client = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: [{ type: 'text', text: 'before' }, { type: 'image', mimeType: 'image/png' }] },
{ content: blocks },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' })
if (result.isError) throw new Error('expected MCP success')
expect(result.value).toEqual({ content: blocks })
})
it('preserves primitive JSON MCP blocks while Native rendering marks them unsupported', async () => {
const blocks = [42, null, ['nested']] satisfies JsonValue[]
const client = createMockClient(
[{ name: 'primitive-blocks', inputSchema: { type: 'object' } }],
{ content: blocks },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({
callId: CallId('primitive'), name: 'mcp__srv__primitive-blocks', arguments: {},
})
expect(result.content[0]).toEqual({
type: 'text',
text: '[unsupported content type: unknown]\n[unsupported content type: unknown]\n[unsupported content type: unknown]',
})
if (result.isError) throw new Error('expected primitive MCP blocks to remain a successful JSON value')
expect(result.value).toEqual({ content: blocks })
})
it('validates structuredContent when the advertised output schema is supported', async () => {
const outputSchema = {
type: 'object',
additionalProperties: false,
properties: { answer: { type: 'integer' } },
required: ['answer'],
}
const valid = createMockClient(
[{ name: 'structured', inputSchema: { type: 'object' }, outputSchema }],
{ content: [{ type: 'text', text: '42' }], structuredContent: { answer: 42 } },
)
await syncTools(valid as never, ctx, defaultOpts, new Map())
const success = await ctx.tools.execute({ callId: CallId('valid'), name: 'mcp__srv__structured', arguments: {} })
if (success.isError) throw new Error('expected supported structuredContent to validate')
expect(success.value).toEqual({ content: [{ type: 'text', text: '42' }], structuredContent: { answer: 42 } })
const invalidCtx = await mountRegistry()
const invalid = createMockClient(
[{ name: 'structured', inputSchema: { type: 'object' }, outputSchema }],
{ content: [{ type: 'text', text: 'wrong' }], structuredContent: { answer: 'forty-two' } },
)
await syncTools(invalid as never, invalidCtx, defaultOpts, new Map())
const failure = await invalidCtx.tools.execute({ callId: CallId('invalid'), name: 'mcp__srv__structured', arguments: {} })
expect(failure.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } })
expect(failure.content[0]?.type === 'text' ? failure.content[0].text : '')
.toContain('value.structuredContent.answer')
})
it('falls back to JsonValue for unsupported advertised output schemas', async () => {
const client = createMockClient(
[{
name: 'future-schema',
inputSchema: { type: 'object' },
outputSchema: { type: 'object', patternProperties: { '^x-': { type: 'string' } } },
}],
{ content: [], structuredContent: ['kept', { nested: true }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('fallback'), name: 'mcp__srv__future-schema', arguments: {} })
if (result.isError) throw new Error('unsupported MCP output schemas must fall back')
expect(result.value).toEqual({ content: [], structuredContent: ['kept', { nested: true }] })
})
it('maps isError to an error result via throw', async () => {
@@ -282,6 +357,7 @@ describe('tool execution', () => {
expect(result.isError).toBe(true)
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' })
expect('value' in result).toBe(false)
})
it('passes abort signal to callTool', async () => {
@@ -313,6 +389,38 @@ describe('tool execution', () => {
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' })
})
it('preserves structuredContent on a successful legacy result', async () => {
const client = createMockClient([{ name: 'legacy-structured', inputSchema: { type: 'object' } }])
client.callTool.mockResolvedValue({
toolResult: 'legacy',
structuredContent: { answer: 42 },
})
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({
callId: CallId('legacy-structured'), name: 'mcp__srv__legacy-structured', arguments: {},
})
if (result.isError) throw new Error('expected legacy structured result success')
expect(result.value).toEqual({
content: [{ type: 'text', text: '"legacy"' }],
structuredContent: { answer: 42 },
})
})
it('maps a legacy isError reply to failure', async () => {
const client = createMockClient([{ name: 'legacy-error', inputSchema: { type: 'object' } }])
client.callTool.mockResolvedValue({ toolResult: { reason: 'nope' }, isError: true })
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({
callId: CallId('legacy-error'), name: 'mcp__srv__legacy-error', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.error?.message).toBe('{"reason":"nope"}')
})
})
describe('tool execution edge cases', () => {
@@ -423,7 +531,7 @@ describe('tool execution edge cases', () => {
const client = createMockClient(
[{ name: 'legacy2', inputSchema: { type: 'object' } }],
)
client.callTool.mockResolvedValue({})
client.callTool.mockResolvedValue({ toolResult: undefined, structuredContent: undefined })
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} })
@@ -431,6 +539,18 @@ describe('tool execution edge cases', () => {
expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' })
})
it('handles a legacy result with neither content nor toolResult', async () => {
const client = createMockClient(
[{ name: 'legacy-empty', inputSchema: { type: 'object' } }],
)
client.callTool.mockResolvedValue({})
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('legacy-empty'), name: 'mcp__srv__legacy-empty', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' })
})
it('handles isError with non-text content (fallback error message)', async () => {
const client = createMockClient(
[{ name: 'err_notext', inputSchema: { type: 'object' } }],

View File

@@ -149,7 +149,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
])
const synthetic = loaded.events.find(e => e.type === 'tool/result')
expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({
callId: CallId('call-x'), isError: true, error: { code: 'interrupted' },
callId: CallId('call-x'), isError: true, error: { info: { code: 'interrupted' } },
})
// The synthetic result carries the SAME callId as the orphaned tool-call,
// so deriveMessages() pairs them — no provider-invalid dangling call.

View File

@@ -16,7 +16,7 @@ The plugin contributes one user-role `<system-reminder>` catalog through `agent/
|---|---|---|
| `name` | string (required) | Exact kebab-case skill name from the available skills listing. |
Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers resolve the winning skill. A successful call returns one text result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`.
Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers resolve the winning skill. A successful call returns canonical `{ name, provider, resourceBase?, content }`, excluding catalog ranking and provider-internal machinery; its Native renderer produces one text result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`.
Resource guidance resolves only paths or URLs explicitly referenced by the instructions against `resourceBase`; scripts, references, and assets load on demand, and the result does not enumerate a skill directory. Local providers may supply a directory, while remote or embedded providers may supply a URL or opaque loading guidance.

View File

@@ -42,6 +42,46 @@ export function apply(ctx: Context, config: Config = {}): void {
parameters: {
name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string', required: true },
provider: { type: 'string', required: true },
resourceBase: {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'directory' },
path: { type: 'string', required: true },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'url' },
url: { type: 'string', required: true },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'opaque' },
description: { type: 'string', required: true },
},
},
],
},
content: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: renderSkillContent(value) }],
},
async execute(args, exec) {
if (!isSkillName(args.name)) {
throw new Error(`invalid skill name "${args.name}"`)
@@ -53,7 +93,14 @@ export function apply(ctx: Context, config: Config = {}): void {
if (skill.disableModelInvocation === true) {
throw new Error(`skill "${args.name}" is not available for model invocation`)
}
return [{ type: 'text', text: renderSkillContent(skill) }]
return {
name: skill.name,
provider: skill.provider,
...skill.resourceBase !== undefined ? {
resourceBase: { ...skill.resourceBase },
} : {},
content: skill.content,
}
},
presentCall(args) {
return { card: 'generic', title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name }
@@ -77,7 +124,7 @@ export function apply(ctx: Context, config: Config = {}): void {
})
}
function renderSkillContent(skill: SkillDefinition): string {
function renderSkillContent(skill: Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'>): string {
const resourceHint = renderResourceHint(skill)
return [
`<skill_content name="${escapeAttr(skill.name)}">`,
@@ -92,7 +139,7 @@ function renderSkillContent(skill: SkillDefinition): string {
].join('\n')
}
function renderResourceHint(skill: SkillDefinition): string[] {
function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceBase'>): string[] {
const base = skill.resourceBase
if (base === undefined) {
return [
@@ -116,8 +163,10 @@ function renderResourceHint(skill: SkillDefinition): string[] {
`Resources for this skill: ${escapeText(base.description)}`,
'Load referenced resources only as needed.',
]
/* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */
default:
return assertNever(base, 'SkillResourceBase.kind')
/* v8 ignore stop */
}
}

View File

@@ -6,7 +6,7 @@ import { Context } from 'cordis'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
@@ -185,7 +185,7 @@ describe('dsh-tool-skill', () => {
const ctx = await setup(home)
ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' })
const { agent, scope } = await mintAgentScope(ctx, '/workspace')
scope.ctx.tools.register(defineTool({
scope.ctx.tools.register(defineContentToolFixture({
name: 'skill',
description: 'A scoped tool with unrelated semantics.',
parameters: {},
@@ -226,6 +226,13 @@ describe('dsh-tool-skill', () => {
})
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected skill success')
expect(result.value).toEqual({
name: 'project-skill',
provider: 'local',
resourceBase: { kind: 'directory', path: join(project, '.dsh/skills/project-skill') },
content: 'Project instructions.',
})
const block = result.content[0]
expect(block?.type).toBe('text')
if (block?.type !== 'text') throw new Error('expected text skill result')
@@ -283,7 +290,7 @@ describe('dsh-tool-skill', () => {
expect(provider.content[0].text).toContain('<skill_resources>\nResources for this skill are managed by provider "runtime".\nLoad referenced resources only as needed.\n</skill_resources>')
})
it('fails loud on an unknown resource base kind', async () => {
it('rejects an unknown resource-base kind at the canonical output boundary', async () => {
const home = await tempDir('tool-resource-assert-never')
const ctx = await setup(home)
ctx.skills.register({
@@ -298,9 +305,10 @@ describe('dsh-tool-skill', () => {
const result = await ctx.tools.execute({ callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } })
expect(result.isError).toBe(true)
expect(result.error?.info?.code).toBe('INVALID_TOOL_OUTPUT')
const block = result.content[0]
if (block?.type !== 'text') throw new Error('expected text tool result')
expect(block.text).toContain('unreachable variant')
expect(block.text).toContain('value.resourceBase')
})
it('returns isError for unknown, invalid, and model-disabled skills', async () => {

View File

@@ -13,7 +13,7 @@ This plugin registers **no service** and owns no storage or preview mechanics: p
## Behavior
1. Let the tool run (delegates via `next()`, so it bounds whatever a downstream hook accepted).
2. Skip `read` (avoids a `read → spill → read again` loop) and any non-`accept` decision (a `block`'s corrective feedback passes through).
2. Skip nested executions (`exec.parent` is present), accepted value replacements (the registry must revalidate and rerender them), `read` (avoids a `read → spill → read again` loop), and any non-`accept` decision (a `block`'s corrective feedback passes through).
3. Flatten the accepted content only when it is **plain text** (all `text` blocks); a result with any non-text block is left untouched.
4. If its UTF-8 size is `≤ maxInlineBytes`, leave it unchanged.
5. Otherwise save the full text and replace the result with a preview + this notice, sized so the whole replacement (preview + blank line + notice) stays within `maxInlineBytes` — the notice's byte cost is reserved out of the budget, so the preview shrinks to fit and the model-facing result never exceeds the cap:
@@ -26,11 +26,11 @@ This plugin registers **no service** and owns no storage or preview mechanics: p
When the notice alone fills the budget (a tiny cap or a long locator) the preview is empty and only the notice is returned. If even that notice-only replacement would exceed `maxInlineBytes`, the policy keeps the inline result — it never emits a replacement over the cap (and a within-cap replacement is always smaller than the original, so this also means spilling never adds bytes).
**Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result.
**Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result. A successful replacement changes only `content`; the canonical programmatic value is preserved.
## Scope
The policy sees only the FINAL formatted tool result — not a tool's internal resource. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. Tool-owned early spill (bash streams, subagent rollouts) is future work — see the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md).
The policy sees only the FINAL formatted surface result—not a tool's internal resource or canonical value. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. `glob`/`grep` own item-level surface spill because their complete acquired values still exist before rendering; bash streams own acquisition-time spill. See the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md).
## Model Experience
@@ -38,7 +38,7 @@ The policy sees only the FINAL formatted tool result — not a tool's internal r
#### What the model sees
Results at or below `maxInlineBytes`, `read` results, blocked decisions, and results containing non-text blocks are unchanged. An oversized plain-text result becomes a bounded head/tail preview followed by `(Omitted <bytes> bytes. Full formatted result stored at: <locator>. <retrievalHint>)`; storage or ownership failures leave the original result visible.
Results at or below `maxInlineBytes`, nested results, `read` results, blocked decisions, and results containing non-text blocks are unchanged. An oversized plain-text surface result becomes a bounded head/tail preview followed by `(Omitted <bytes> bytes. Full formatted result stored at: <locator>. <retrievalHint>)`; storage or ownership failures leave the original result visible.
#### Token effect

View File

@@ -16,15 +16,20 @@
* - Plain-text results only: a result carrying any non-text block is left
* untouched (the policy knows only the final formatted text, not tool
* internals).
* - Nested composite calls are skipped; only their outer surface result may
* become model-facing and spillable.
* - Accepted value replacements pass through for registry revalidation and
* rendering; this presentation policy cannot also replace content in the
* same mutually exclusive decision.
* - `read` is skipped to avoid a `read → spill → read again` loop.
* - Best-effort: no session owner, no `ctx.spillStore` backend, or a save
* failure ⇒ log and return the original result. A spill failure must NEVER
* turn a successful tool call into an `isError` or hide the inline result.
*
* It COMPOSES with other post-execute listeners: it delegates via `next()` and
* bounds the resulting `accept` content, so a hook that replaced the content
* still has its replacement bounded, and a `block` decision passes through
* unchanged.
* bounds the resulting content projection, so a hook that replaced content
* still has its replacement bounded, while value replacements and `block`
* decisions pass through unchanged.
*
* @module @deepseek-ai/dsh-spill-policy
*/
@@ -109,7 +114,8 @@ export function apply(ctx: Context, config: Config): void {
// accepted plain-text results, never corrective feedback.
const decision = await next()
// Skip `read` to avoid a read → spill → read again loop.
if (decision.kind !== 'accept' || exec.name === 'read') return decision
if (decision.kind !== 'accept' || Object.hasOwn(decision, 'value')
|| exec.parent !== undefined || exec.name === 'read') return decision
const content = decision.content ?? result.content
const text = flattenPlainText(content)

View File

@@ -15,8 +15,8 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
@@ -39,7 +39,7 @@ class StubStore extends SpillStore {
/** A tool returning `text` verbatim (name configurable so we can register `read`). */
function textTool(name: string, text: string) {
return defineTool({
return defineContentToolFixture({
name,
description: name,
parameters: {},
@@ -159,7 +159,7 @@ describe('oversized plain-text replacement', () => {
it('leaves a result with a non-text block unchanged', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 5 })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'mixed',
description: 'mixed',
parameters: {},
@@ -183,6 +183,21 @@ describe('read skip', () => {
})
})
describe('nested-call skip', () => {
it('leaves nested composite results complete and spillable only through their outer call', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
const body = 'x'.repeat(1000)
ctx.tools.register(textTool('nested', body))
const nested = {
...exec('nested'),
parent: Symbol('outer') as ToolExecutionToken,
}
const result = await ctx.tools.execute(nested)
expect(textOf(result.content)).toBe(body)
expect(spill?.saves).toHaveLength(0)
})
})
describe('best-effort fallback', () => {
it('keeps the original result when saveText fails', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
@@ -238,6 +253,21 @@ describe('composition', () => {
expect(textOf(result.content)).toContain('Full formatted result stored at')
expect(result.additionalContexts).toEqual([context])
})
it('passes a downstream value replacement through for registry rendering', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
const replacement = [{ type: 'text' as const, text: 'z'.repeat(500) }]
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: replacement }))
ctx.tools.register(textTool('small', 'tiny'))
const result = await ctx.tools.execute(exec('small'))
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected replacement success')
expect(result.value).toEqual(replacement)
expect(textOf(result.content)).toBe('z'.repeat(500))
expect(spill?.saves).toHaveLength(0)
})
})
describe('cap invariant', () => {

View File

@@ -60,7 +60,7 @@ Independent of the parent request cache. The child's later history is append-onl
#### What the model sees
A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Success returns `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `<tool>` is not executed``.
A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Its canonical acknowledgement is `{ recorded: true }`, rendered as `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `<tool>` is not executed``.
##### Structured-output instruction

View File

@@ -12,7 +12,7 @@
import type { Context } from 'cordis'
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
@@ -74,7 +74,16 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch
childCtx.tools.register({
...schemaEntry,
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
output: {
schema: {
type: 'object',
properties: { recorded: { type: 'boolean', const: true } },
required: ['recorded'],
additionalProperties: false,
},
render: () => [{ type: 'text', text: 'Structured output recorded.' }],
},
execute(args: unknown, exec: ToolExecution): Promise<{ recorded: true }> {
const violations = validateJsonSchemaValue(schema, args)
// ToolArgsError → isError result with INVALID_ARGS: the model retries
// within the same turn, exactly like a schema-validated defineTool call.
@@ -83,7 +92,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch
// waterfalls may still turn the success into an error. ToolRegistry has
// already frozen model-bound arguments at the actual input boundary.
staged.set(exec, { value: args })
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
return Promise.resolve({ recorded: true })
},
})

View File

@@ -8,7 +8,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
import {
@@ -85,10 +85,15 @@ describe('in-process structured output', () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }),
])
let acknowledgement: unknown
ctx.on('tools/result', (exec, toolResult) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL && !toolResult.isError) acknowledgement = toolResult.value
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 42, note: 'done' })
expect(acknowledgement).toEqual({ recorded: true })
await run.dispose()
})
@@ -119,15 +124,15 @@ describe('in-process structured output', () => {
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register({
ctx.tools.register(defineContentToolFixture({
name: 'side_effect',
description: 'probe',
parameters: { type: 'object', properties: {} },
parameters: {},
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
}))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
@@ -147,15 +152,15 @@ describe('in-process structured output', () => {
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register({
ctx.tools.register(defineContentToolFixture({
name: 'side_effect',
description: 'probe',
parameters: { type: 'object', properties: {} },
parameters: {},
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
}))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Registered after the child and prepended: this listener returns allow
// after every downstream pre-execute decision. The service-owned guard
@@ -184,15 +189,15 @@ describe('in-process structured output', () => {
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register({
ctx.tools.register(defineContentToolFixture({
name: 'side_effect',
description: 'probe',
parameters: { type: 'object', properties: {} },
parameters: {},
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
}))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
// The call ran BEFORE captured was set: the deny gate only guards the
@@ -589,12 +594,12 @@ describe('in-process structured output', () => {
])
// A global tool sorts lexicographically after structured_output, while a
// global section above the 190 band follows the capture instruction.
ctx.tools.register({
ctx.tools.register(defineContentToolFixture({
name: 'zz_probe',
description: 'probe',
parameters: { type: 'object', properties: {} },
parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: 'x' }]),
})
}))
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
@@ -646,7 +651,7 @@ describe('in-process structured output', () => {
agent: parent,
})
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('UNKNOWN_TOOL')
expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
})
it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => {
@@ -657,7 +662,7 @@ describe('in-process structured output', () => {
arguments: { answer: 1 },
})
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('UNKNOWN_TOOL')
expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
})
it('a failed execution stage is discarded and never promoted by a later call', async () => {

View File

@@ -10,6 +10,7 @@ import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-sub
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as spawn from '../src/index.ts'
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -383,10 +384,10 @@ describe('dsh-subagent-spawn', () => {
toolCallResponse('c1', 'forbidden_tool', {}),
textResponse('done'),
])
ctx.tools.register({
ctx.tools.register(defineContentToolFixture({
name: 'forbidden_tool', description: 'global', parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]),
})
}))
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent,

View File

@@ -6,9 +6,9 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C
Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns.
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns final text; abort, refusal, token limit, and other failures become errored tool results without partial output.
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output.
With `run_in_background: true`, the tool registers the parent-owned task before starting the provider. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md).
With `run_in_background: true`, the tool registers the parent-owned task before starting the provider and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md).
`toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).

View File

@@ -12,6 +12,7 @@ import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
@@ -95,6 +96,16 @@ function outputText(blocks: ContentBlock[]): string {
.join('')
}
/** Render text blocks from the canonical JSON block array without trusting arbitrary values. */
function outputValueText(values: JsonValue[]): string {
return values
.filter((value): value is { type: 'text'; text: string } =>
typeof value === 'object' && value !== null && !Array.isArray(value)
&& value.type === 'text' && typeof value.text === 'string')
.map(value => value.text)
.join('')
}
/** A non-`completed` stop reason means the child did not finish cleanly. */
function stopReasonError(result: SubagentResult): string | undefined {
switch (result.stopReason) {
@@ -268,7 +279,36 @@ export function apply(ctx: Context, config: Config): void {
},
} : {},
},
async execute(args, exec): Promise<ContentBlock[]> {
output: {
schema: {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'background' },
taskId: { type: 'string', required: true },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'foreground' },
runId: { type: 'string', required: true },
output: { type: 'array', required: true, items: { type: 'json' } },
},
},
],
},
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? `started background subagent task ${value.taskId}`
: outputValueText(value.output),
}],
},
async execute(args, exec) {
const parent = exec.agent
if (!parent) {
// Non-agent callers provide no parent for delegation ownership.
@@ -308,7 +348,7 @@ export function apply(ctx: Context, config: Config): void {
}
},
})
return [{ type: 'text', text: `started background subagent task ${id}` }]
return { kind: 'background' as const, taskId: id }
}
const request = startRequest(
@@ -327,7 +367,13 @@ export function apply(ctx: Context, config: Config): void {
// The registry converts this throw to isError; partial output is not success.
throw new Error(error)
}
return [{ type: 'text', text: outputText(result.output) }]
return {
kind: 'foreground' as const,
runId: run.id,
// Content blocks already cross durable JSON boundaries elsewhere;
// the registry performs the authoritative lossless snapshot here.
output: result.output as unknown as JsonValue[],
}
} finally {
// Dispose before returning so no child session outlives the call.
await run.dispose()

View File

@@ -62,6 +62,12 @@ describe('dsh-tool-subagent', () => {
const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' })
const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected subagent success')
expect(result.value).toEqual({
kind: 'foreground',
runId: 'scripted-subagent:mock:parent-1',
output: [{ type: 'text', text: 'child says hi' }],
})
expect(text(result)).toBe('child says hi')
})
@@ -637,6 +643,8 @@ describe('dsh-tool-subagent background mode', () => {
const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent })
expect(start.isError).toBe(false)
if (start.isError) throw new Error('expected background subagent success')
expect(start.value).toEqual({ kind: 'background', taskId: 'subagent-1' })
expect(text(start)).toBe('started background subagent task subagent-1')
const collected = await ctx.tools.execute({

View File

@@ -165,7 +165,7 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
// A result needs a prior matching call in the same step. (The converse
// does NOT hold: a call may have no result — a throwing tool-execution
// pipeline step ends the turn with no tool/result, which is legal.)
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
const syntheticInterrupted = event.data.isError && event.data.error?.info?.code === 'interrupted'
if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
}

View File

@@ -217,7 +217,7 @@ describe('session-log invariants', () => {
callId: CallId('crashed'),
content: [{ type: 'text', text: 'interrupted' }],
isError: true,
error: { name: 'InterruptedError', code: 'interrupted' },
error: { message: 'interrupted', info: { name: 'InterruptedError', code: 'interrupted' } },
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
@@ -500,7 +500,7 @@ describe('surface contract under the invariants composition', () => {
callId: CallId('rewrite'),
content: [{ type: 'text' as const, text: 'original' }],
isError: true,
error: { name: 'ExitError', code: 'EXIT_1' },
error: { message: 'exit 1', info: { name: 'ExitError', code: 'EXIT_1' } },
meta: { presentation: { kind: 'terminal', output: 'full output' } },
futureField: { nested: ['preserve', 1] },
}
@@ -585,7 +585,7 @@ describe('surface contract under the invariants composition', () => {
['callId', { callId: CallId('forged') }],
['turn', { turn: 2 }],
['step', { step: 2 }],
['error', { error: { name: 'ExitError', code: 'DIFFERENT' } }],
['error', { error: { message: 'exit 1', info: { name: 'ExitError', code: 'DIFFERENT' } } }],
['meta', { meta: { presentation: { kind: 'generic' } } }],
['future data', { futureField: { nested: ['changed'] } }],
])('rejects a content rewrite with altered %s', async (_label, altered) => {

View File

@@ -10,6 +10,8 @@ The model-facing control surface for `ctx.tasks`: three kind-independent tools,
All three use generic ACP cards: `read` for output and list, `execute` for kill.
Their canonical values are `{ text, task }`, `PublicTaskSnapshot[]`, and `{ outcome: 'cancellation-requested' | 'already-finished', task }`. A public snapshot carries id, kind, label, status/detail, and start/finish times; it deliberately omits `ownerSession` and the internal `reported` notice bit. Native renderers preserve the status and acknowledgement text above.
## Completion notices
An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's session. Injection is durable context for the next request, not a wake-up. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice; owner-disposal races are contained.

View File

@@ -30,12 +30,55 @@ export const Config: z<Config> = z.object({
maxWaitTimeoutMs: z.number().min(1).default(600_000),
})
/** Task state safe for model-authored programs; ownership/bookkeeping fields are omitted. */
export interface PublicTaskSnapshot {
id: string
kind: string
label: string
status: TaskSnapshot['status']
detail?: string
startedAt: number
finishedAt?: number
}
/** Shared schema for task-control outputs. */
const PUBLIC_TASK_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
kind: { type: 'string', required: true },
label: { type: 'string', required: true },
status: {
type: 'string',
required: true,
enum: ['running', 'stopping', 'completed', 'killed', 'failed'],
},
detail: { type: 'string' },
startedAt: { type: 'integer', required: true },
finishedAt: { type: 'integer' },
},
} as const
/** Remove task ownership and notification bookkeeping from a registry snapshot. */
function publicTask(snapshot: TaskSnapshot): PublicTaskSnapshot {
return {
id: snapshot.id,
kind: snapshot.kind,
label: snapshot.label,
status: snapshot.status,
...snapshot.detail !== undefined ? { detail: snapshot.detail } : {},
startedAt: snapshot.startedAt,
...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {},
}
}
/**
* Render generic status with optional producer detail.
* @param snapshot - task state to render.
* @returns a bracketed status line.
*/
export function statusLine(snapshot: TaskSnapshot): string {
export function statusLine(snapshot: Pick<TaskSnapshot, 'status' | 'detail'>): string {
return snapshot.detail !== undefined
? `[status: ${snapshot.status}, ${snapshot.detail}]`
: `[status: ${snapshot.status}]`
@@ -98,6 +141,21 @@ export function apply(ctx: Context, config: Config): void {
wait: { type: 'boolean', description: 'Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive.' },
timeout_ms: { type: 'number', description: 'Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum.' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
text: { type: 'string', required: true },
task: { ...PUBLIC_TASK_SCHEMA, required: true },
},
},
render: (_args, value) => {
const body = value.text.length > 0 ? value.text : '(no new output)'
const separator = body.endsWith('\n') ? '' : '\n'
return [{ type: 'text', text: `${body}${separator}${statusLine(value.task)}` }]
},
},
async execute(args, exec) {
const id = validateTaskId(args.task_id)
if (args.wait === true) {
@@ -105,9 +163,7 @@ export function apply(ctx: Context, config: Config): void {
await ctx.tasks.wait(id, timeout, exec.agent, exec.signal)
}
const read = ctx.tasks.read(id, exec.agent)
const body = read.text.length > 0 ? read.text : '(no new output)'
const separator = body.endsWith('\n') ? '' : '\n'
return [{ type: 'text', text: `${body}${separator}${statusLine(read.snapshot)}` }]
return { text: read.text, task: publicTask(read.snapshot) }
},
presentCall: args => presentTaskCall(`Read output from background task ${args.task_id}`, 'read', args.task_id),
}))
@@ -116,12 +172,18 @@ export function apply(ctx: Context, config: Config): void {
name: 'task_list',
description: 'List your background tasks (running and finished) with their ids, kinds, and statuses.',
parameters: {},
output: {
schema: { type: 'array', items: PUBLIC_TASK_SCHEMA },
render: (_args, tasks) => [{
type: 'text',
text: tasks.length === 0
? '(no background tasks)'
: tasks.map(t => `${t.id} [${t.kind}] ${t.status} — ${t.label}`).join('\n'),
}],
},
execute(_args, exec) {
const tasks = ctx.tasks.list(exec.agent)
const text = tasks.length === 0
? '(no background tasks)'
: tasks.map(t => `${t.id} [${t.kind}] ${t.status} — ${t.label}`).join('\n')
return Promise.resolve([{ type: 'text', text }])
return Promise.resolve(tasks.map(publicTask))
},
presentCall: () => presentTaskCall('List background tasks', 'read'),
}))
@@ -133,15 +195,35 @@ export function apply(ctx: Context, config: Config): void {
task_id: { type: 'string', required: true, description: 'Task id returned by the tool that started the background work.' },
reason: { type: 'string', description: 'Optional short reason, recorded in the log and forwarded to the task.' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
outcome: {
type: 'string',
required: true,
enum: ['cancellation-requested', 'already-finished'],
},
task: { ...PUBLIC_TASK_SCHEMA, required: true },
},
},
render: (_args, value) => [{
type: 'text',
text: value.outcome === 'already-finished'
? `task ${value.task.id} had already finished ${statusLine(value.task)}`
: `requested cancellation of task ${value.task.id}`,
}],
},
execute(args, exec) {
const id = validateTaskId(args.task_id)
const result = ctx.tasks.kill(id, exec.agent, args.reason)
if (result === 'already-finished') {
// A snapshot describes terminal state without consuming pending output.
const snapshot = ctx.tasks.get(id, exec.agent)
return Promise.resolve([{ type: 'text', text: `task ${id} had already finished ${statusLine(snapshot)}` }])
}
return Promise.resolve([{ type: 'text', text: `requested cancellation of task ${id}` }])
// A snapshot describes current state without consuming pending output.
const snapshot = publicTask(ctx.tasks.get(id, exec.agent))
return Promise.resolve({
outcome: result === 'already-finished' ? 'already-finished' as const : 'cancellation-requested' as const,
task: snapshot,
})
},
presentCall: args => presentTaskCall(`Kill background task ${args.task_id}`, 'execute', args.task_id),
}))

View File

@@ -114,7 +114,16 @@ describe('task_output', () => {
ctx.tasks.start(producer({ readOutput: () => chunks.shift() ?? '' }).spec)
// A body already ending in a newline gets no doubled separator.
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('line one\n[status: running]')
const first = await call(ctx, 'task_output', { task_id: 'bash-1' })
if (first.isError) throw new Error('expected task_output success')
const firstValue = first.value as { text: string; task: Record<string, unknown> }
expect(firstValue).toMatchObject({
text: 'line one\n',
task: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'running' },
})
expect(firstValue.task).not.toHaveProperty('ownerSession')
expect(firstValue.task).not.toHaveProperty('reported')
expect(text(first)).toBe('line one\n[status: running]')
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('(no new output)\n[status: running]')
})
@@ -171,7 +180,17 @@ describe('task_list', () => {
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(text(await call(ctx, 'task_list', {}, alice))).toBe([
const listed = await call(ctx, 'task_list', {}, alice)
if (listed.isError) throw new Error('expected task_list success')
const listedValue = listed.value as Array<Record<string, unknown>>
expect(listedValue).toHaveLength(3)
expect(listedValue[0]).toMatchObject({ id: 'bash-1', kind: 'bash', label: 'pnpm test', status: 'running' })
expect(listedValue[2]).toMatchObject({ id: 'bash-2', kind: 'bash', label: 'build', status: 'completed', detail: 'exit code: 0' })
for (const task of listedValue) {
expect(task).not.toHaveProperty('ownerSession')
expect(task).not.toHaveProperty('reported')
}
expect(text(listed)).toBe([
'bash-1 [bash] running — pnpm test',
'subagent-1 [subagent] running — open research',
'bash-2 [bash] completed — build',
@@ -189,6 +208,14 @@ describe('task_kill', () => {
ctx.tasks.start(p.spec)
const result = await call(ctx, 'task_kill', { task_id: 'bash-1', reason: 'superseded' })
if (result.isError) throw new Error('expected task_kill success')
const killValue = result.value as { outcome: string; task: Record<string, unknown> }
expect(killValue).toMatchObject({
outcome: 'cancellation-requested',
task: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'stopping' },
})
expect(killValue.task).not.toHaveProperty('ownerSession')
expect(killValue.task).not.toHaveProperty('reported')
expect(text(result)).toBe('requested cancellation of task bash-1')
expect(p.cancels).toEqual(['superseded'])
})
@@ -201,8 +228,13 @@ describe('task_kill', () => {
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(text(await call(ctx, 'task_kill', { task_id: 'bash-1' })))
.toBe('task bash-1 had already finished [status: completed, exit code: 0]')
const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' })
if (killed.isError) throw new Error('expected task_kill success')
expect(killed.value).toMatchObject({
outcome: 'already-finished',
task: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'completed', detail: 'exit code: 0' },
})
expect(text(killed)).toBe('task bash-1 had already finished [status: completed, exit code: 0]')
// The kill described the task via a non-consuming snapshot: the delta is intact.
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('unread tail\n[status: completed, exit code: 0]')
})

View File

@@ -19,7 +19,7 @@ For a tool that **declares a `timeoutMs`** the listener:
1. Reads the budget from the tool's own declaration in the registry (`ctx.tools.get(exec.name)?.timeoutMs`) and arms `deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')` — one signal fusing the caller's abort with this plugin's timer (`@deepseek-ai/dsh-timeout`).
2. Swaps that derived signal onto `exec` for the downstream dispatch, then restores the caller's own signal afterward (cordis `next()` ignores passed arguments, so the wrapper mutates the shared `exec` in place; restoring keeps `tools/post-execute` seeing the caller's signal).
3. After dispatch, if `timeoutOf(d.signal, 'TOOL_TIMEOUT')` matches — this plugin's own timer fired — replaces the result with a structured `TOOL_TIMEOUT` tool result: `{ isError: true, error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, content: 'Error: tool call timed out after <ms>ms' }`.
3. After dispatch, if `timeoutOf(d.signal, 'TOOL_TIMEOUT')` matches — this plugin's own timer fired — replaces the result with a structured `TOOL_TIMEOUT` tool result: `{ isError: true, error: { message, info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' } }, content: 'Error: tool call timed out after <ms>ms' }`.
A tool that **declares no budget** delegates untouched (no deadline).

Some files were not shown because too many files have changed in this diff Show More