With a job registry composed, tool-bash and tool-pwsh now register every command with ctx.jobs as it starts and wait on the job with the registry's own bounded wait: a command that finishes in time returns the ordinary foreground result and its record is removed, one that outlives the timeout returns the same job id it already had, and a kill from outside the call is reported as `[stopped: <reason>]`. Nothing changes hands at the deadline, so the shell seam loses `onExpiry: 'offer'`, `ShellExecution.promotion`, and `ShellPromotionOffer`, and both local executors drop their offer arm. The registry stays optional: the tools register a foreground-only definition while no registry is composed and swap to the job-backed one inside a `ctx.inject(['jobs'])` fork, so a composition without jobs keeps a plain bash with no job tools in the model's schema. A refused registration at the start (admission, no controller) runs under the executor's deadline kill, and a wait that expires before the process spawned ends as the preparation timeout. JobRegistry gains `remove(id, caller?)` and the `settled` event reports `awaited`, so dsh-tool-jobs no longer keeps its own wait ledger; it withholds notices for awaited settlements and for kills the model requested. The tool catalog harvest composes the registry so the shipped schema stays documented, and the two compaction fixtures move their context window from 10000 to 10300 tokens because the branch's larger tool schemas pushed their condensed history over the old trigger, which requested a fourth scripted model call.
129 lines
5.7 KiB
TypeScript
129 lines
5.7 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 and the job hand-off guidance.
|
|
* @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}]\n`
|
|
+ 'The command keeps running in the background. You will be notified when it finishes; '
|
|
+ 'read newer output with job_output, stop it with job_kill.'
|
|
}
|
|
|
|
/**
|
|
* 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'
|