feat: add canonical typed tool outputs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user