- tool-jobs: a completion notice carries the job's unread output when it fits inlineOutputBytes (default 2048); job_output withdraws a notice still pending, and the label is its first line bounded to 120 characters - tool-bash, tool-pwsh: the promotion result keeps only the still-running marker - subagent: a settlement notice omits the closing message when the child's last action was a message to its parent
127 lines
5.6 KiB
TypeScript
127 lines
5.6 KiB
TypeScript
/**
|
|
* Model-facing result rendering for the bash tool.
|
|
*
|
|
* @module @deepseek-ai/dsh-tool-bash/render
|
|
*/
|
|
|
|
import type { ShellRunResult, ShellSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-shell'
|
|
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
|
import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
|
|
|
|
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
|
|
function streamText(output: CollectedOutput): string {
|
|
if (!output.truncated) return output.text
|
|
return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
|
|
}
|
|
|
|
/**
|
|
* Shape one finished run into the text the model sees: stdout, then a marked
|
|
* stderr section, then exit-status markers. Non-zero exits are reported, not
|
|
* errored — the model decides how to react; only infrastructure failures
|
|
* (spawn errors, aborts) surface as isError results.
|
|
* @param result - the completed foreground run from the executor.
|
|
* @param escalationModes - the escalation targets this composition advertises;
|
|
* non-empty adds the same-turn escalation hint after a denial marker
|
|
* (default `[]`: no hint).
|
|
* @returns the model-facing text: output body (or `(no output)`), then any timeout/stopped/signal/exit markers, each on its own line.
|
|
*/
|
|
export function renderResult(
|
|
result: ShellRunResult & { stopped?: string },
|
|
escalationModes: readonly SandboxMode[] = [],
|
|
): string {
|
|
const out = streamText(result.stdout)
|
|
const err = streamText(result.stderr)
|
|
|
|
let body = out
|
|
if (err.length > 0) {
|
|
// Single newline between sections (stdout usually ends with one already).
|
|
if (body.length > 0 && !body.endsWith('\n')) body += '\n'
|
|
body += `[stderr]\n${err}`
|
|
}
|
|
if (body.length === 0) body = '(no output)'
|
|
|
|
const markers: string[] = []
|
|
// Keep the exit marker last because parseExitStatus anchors there.
|
|
if (result.sandbox?.denied) {
|
|
markers.push(sandboxDenialMarker(result.sandbox.mode))
|
|
// Hint only when the composition exposes escalation, before the final exit marker.
|
|
if (escalationModes.length > 0) {
|
|
markers.push(escalationHintMarker('command'))
|
|
}
|
|
}
|
|
// A command may trap SIGTERM and exit 0 after timeout; still report interruption.
|
|
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
|
|
// A kill from outside the call (the human stopping its job) is not a
|
|
// command failure: the reason tells the model not to retry.
|
|
if (result.stopped !== undefined) markers.push(`[stopped: ${result.stopped}]`)
|
|
if (result.signal !== null) {
|
|
markers.push(`[killed by signal: ${result.signal}]`)
|
|
} else if (result.exitCode !== 0) {
|
|
markers.push(`[exit code: ${result.exitCode}]`)
|
|
}
|
|
if (markers.length === 0) return body
|
|
|
|
if (!body.endsWith('\n')) body += '\n'
|
|
return body + markers.join('\n')
|
|
}
|
|
|
|
/**
|
|
* Shape a foreground call that stopped waiting into the text the model sees:
|
|
* the output captured so far (one consuming registry read taken at that
|
|
* point, so `job_output` continues exactly after it), then the still-running
|
|
* marker. The `tool:jobs` system-prompt section explains how to follow up.
|
|
* @param promoted - the promoted result value: the job id, the wait that
|
|
* expired, and the output so far.
|
|
* @returns the model-facing text for a promoted call.
|
|
*/
|
|
export function renderPromoted(promoted: { jobId: string; timeoutMs: number; output: string }): string {
|
|
const body = promoted.output.length > 0
|
|
? promoted.output.endsWith('\n') ? promoted.output : `${promoted.output}\n`
|
|
: ''
|
|
return `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]`
|
|
}
|
|
|
|
/**
|
|
* Shape the one consuming registry read a foreground call embeds in its
|
|
* result when it stops waiting: the output produced so far, plus the
|
|
* dropped-output notice (naming the job's spill files) when the model cursor
|
|
* fell behind the ring, and the sandbox notices. Later `job_output` reads
|
|
* render the same ring through the job tools.
|
|
* @param delta - the read's chunks as rendered text.
|
|
* @param lossy - whether bytes before the delta were evicted unread.
|
|
* @param spillPaths - the complete-stream files the job currently advertises.
|
|
* @param sandbox - settled sandbox facts, when this was a confined process.
|
|
* @param escalationModes - escalation targets advertised by this composition.
|
|
* @returns the delta text with any loss or sandbox notice appended.
|
|
*/
|
|
export function renderJobRead(
|
|
delta: string,
|
|
lossy: boolean,
|
|
spillPaths: readonly string[],
|
|
sandbox?: ShellSandboxInfo,
|
|
escalationModes: readonly SandboxMode[] = [],
|
|
): string {
|
|
const notices: string[] = []
|
|
if (lossy) {
|
|
notices.push(`[some output was dropped from memory; full output: ${spillPaths.length > 0 ? spillPaths.join(', ') : '(unavailable)'}]`)
|
|
}
|
|
if (sandbox?.runnerFailed) {
|
|
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
|
|
} else if (sandbox?.denied) {
|
|
notices.push(sandboxDenialMarker(sandbox.mode))
|
|
if (escalationModes.length > 0) {
|
|
notices.push(escalationHintMarker('command'))
|
|
}
|
|
}
|
|
if (notices.length === 0) return delta
|
|
return `${delta}${delta.length > 0 && !delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
|
|
}
|
|
|
|
/**
|
|
* The exit-status parse is the shared marker-contract half of the shell-tool
|
|
* rendering story, owned by `@deepseek-ai/dsh-shell` so `dsh-tool-pwsh` reuses
|
|
* it (its renderer emits the same markers). Re-exported here to keep
|
|
* `../src/render.ts` a single import root for bash-tool consumers.
|
|
*/
|
|
export { parseExitStatus, type ParsedExitStatus } from '@deepseek-ai/dsh-shell'
|