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)