@banbolee/dsh-fish-shell 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -41
- package/README.zh.md +63 -0
- package/cordis.patch.yml +39 -32
- package/index.js +60 -33
- package/job-kind.d.ts +24 -0
- package/local.js +13 -14
- package/package.json +52 -14
- package/persistent.js +832 -0
- package/policy.js +405 -0
- package/terminal-fish.js +427 -0
- package/tool.js +546 -112
- package/presets/fish/agent.cordis.yml +0 -241
package/tool.js
CHANGED
|
@@ -9,64 +9,110 @@
|
|
|
9
9
|
* the standalone-spawn version this module used to carry was removed because
|
|
10
10
|
* it bypassed all of that.
|
|
11
11
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
* fish.
|
|
12
|
+
* The functional surface mirrors the official `@deepseek-ai/dsh-tool-bash`
|
|
13
|
+
* (the harness's model-facing `bash` tool) with shell-specific wording
|
|
14
|
+
* swapped to fish: background execution through `ctx.jobs` (the process's
|
|
15
|
+
* non-consuming stream readers as the registry's pull sources, with the
|
|
16
|
+
* sandbox facts joined to the job outcome), same-turn sandbox escalation through
|
|
17
|
+
* `ctx.approval` (strictly-wider modes only, fail-closed), canonical
|
|
18
|
+
* foreground results carrying the complete sandbox facts
|
|
19
|
+
* (`enforcement`/`runnerFailed`), terminal/generic UI presentation, and the
|
|
20
|
+
* `tool:fish` exit-code prompt section. On top of that it keeps this
|
|
21
|
+
* bundle's fish-specific teaching: the description translates bash idioms,
|
|
22
|
+
* and failed runs carry a `[fish syntax]` hint naming the exact fix.
|
|
15
23
|
*
|
|
16
24
|
* @module @banbolee/dsh-fish-shell/tool
|
|
17
25
|
*/
|
|
18
26
|
|
|
27
|
+
/// <reference path="./job-kind.d.ts" />
|
|
28
|
+
|
|
19
29
|
import { isAbsolute, resolve } from 'node:path'
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
30
|
+
import { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
|
31
|
+
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
|
32
|
+
import {
|
|
33
|
+
ESCALATION_TARGETS,
|
|
34
|
+
approveEscalation,
|
|
35
|
+
escalationHintMarker,
|
|
36
|
+
sandboxDenialMarker,
|
|
37
|
+
validateEscalationArgs,
|
|
38
|
+
} from '@deepseek-ai/dsh-sandbox'
|
|
39
|
+
import { DSH_ENV_PREFIX, parseExitStatus } from '@deepseek-ai/dsh-shell'
|
|
22
40
|
|
|
23
|
-
|
|
24
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Type-only reference so `@deepseek-ai/dsh-shell-env`'s module augmentation
|
|
43
|
+
* (`ctx.shellEnv`, the service `inject` above requires) joins this checkJs
|
|
44
|
+
* program: the bundle patch mounts the package at runtime, and this module has
|
|
45
|
+
* no reason to import it as a value.
|
|
46
|
+
* @typedef {import('@deepseek-ai/dsh-shell-env').ShellEnvRegistry} ShellEnvRegistry
|
|
47
|
+
*/
|
|
25
48
|
|
|
26
49
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
50
|
+
* One captured stream with its truncation facts.
|
|
51
|
+
* @typedef {object} FishOutputStream
|
|
52
|
+
* @property {string} text
|
|
53
|
+
* @property {boolean} truncated
|
|
54
|
+
* @property {string} [spillPath]
|
|
30
55
|
*/
|
|
31
|
-
const BUNDLED_PRESET_URL = new URL('./presets/fish/agent.cordis.yml', import.meta.url)
|
|
32
56
|
|
|
33
57
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
58
|
+
* Sandbox facts carried into the canonical result (`ShellSandboxInfo` as
|
|
59
|
+
* lossless JSON).
|
|
60
|
+
* @typedef {object} FishSandboxFacts
|
|
61
|
+
* @property {import('@deepseek-ai/dsh-sandbox').SandboxMode} mode
|
|
62
|
+
* @property {boolean} denied
|
|
63
|
+
* @property {import('@deepseek-ai/dsh-sandbox').SandboxEnforcement} [enforcement]
|
|
64
|
+
* @property {boolean} [runnerFailed]
|
|
37
65
|
*/
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The `fish` tool's parsed arguments. Registration is raw JSON Schema, so the
|
|
69
|
+
* registry hands the body `unknown`; these are the fields the tool validates
|
|
70
|
+
* and narrows at runtime, typed as the schema admits them (every one optional).
|
|
71
|
+
* @typedef {object} FishToolArgs
|
|
72
|
+
* @property {string} [command] - the fish command to execute.
|
|
73
|
+
* @property {string} [description] - the one-line UI description.
|
|
74
|
+
* @property {string} [workdir] - optional working directory (session-relative when not absolute).
|
|
75
|
+
* @property {number} [timeoutMs] - optional foreground deadline.
|
|
76
|
+
* @property {boolean} [run_in_background] - run as a `ctx.jobs` background job.
|
|
77
|
+
* @property {string} [sandbox_permissions] - one-shot escalation target.
|
|
78
|
+
* @property {string} [justification] - the escalation's one-sentence reason.
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The canonical JSON value of one foreground fish run — the `foreground`
|
|
83
|
+
* branch of `OUTPUT_SCHEMA` and what `renderResult` renders. Structural on
|
|
84
|
+
* purpose: a replay holds this projection, not the executor's
|
|
85
|
+
* `ShellRunResult`.
|
|
86
|
+
* @typedef {object} FishForegroundValue
|
|
87
|
+
* @property {number | null} exitCode
|
|
88
|
+
* @property {string | null} signal
|
|
89
|
+
* @property {boolean} timedOut
|
|
90
|
+
* @property {boolean} aborted
|
|
91
|
+
* @property {number} timeoutMs
|
|
92
|
+
* @property {FishOutputStream} stdout
|
|
93
|
+
* @property {FishOutputStream} stderr
|
|
94
|
+
* @property {FishSandboxFacts} [sandbox]
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
export const name = 'fish-tool'
|
|
98
|
+
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
|
|
56
99
|
|
|
57
100
|
/**
|
|
58
101
|
* The model-facing tool description. Written from the model's perspective:
|
|
59
102
|
* the most important fact is that this is FISH, not bash, so the model must
|
|
60
|
-
* translate bash idioms before running them.
|
|
103
|
+
* translate bash idioms before running them. Structured like the official
|
|
104
|
+
* bash tool description (markers, `DSH_*` environment facts, background
|
|
105
|
+
* execution, sandbox escalation) on top of the fish syntax teaching.
|
|
61
106
|
*/
|
|
62
|
-
const
|
|
107
|
+
const BASE_TOOL_DESCRIPTION = `Execute a fish shell command (\`fish -c\`) and return its stdout/stderr. Each call runs in a fresh non-login fish: no state (cwd, variables, functions, history) persists between calls — pass \`workdir\` instead of using \`cd\`. This tool uses the FISH shell, NOT bash; translate bash commands to fish syntax first:
|
|
63
108
|
- Variables: \`set -gx NAME value\` (not \`export NAME=value\`); read them as \`$NAME\`. Use \`env\` to see exported variables.
|
|
64
109
|
- Conditionals/loops: \`if ...; ...; end\`, \`for x in ...; ...; end\`, \`while ...; ...; end\`, \`switch\` instead of \`case\`.
|
|
65
110
|
- Chaining: \`a; and b\` / \`a; or b\` (fish 3 also accepts \`&&\` / \`||\`); background a job with \`cmd &\` and wait with \`wait\`.
|
|
66
111
|
- Command substitution: \`(cmd)\` (fish 3 also accepts \`$(cmd)\`); arithmetic: \`math '1 + 2'\`; string processing: \`string ...\`.
|
|
67
112
|
- Expansion: \`~\`, \`$VAR\`, and globs like \`*.md\` expand; an unmatched glob is an error, not a literal.
|
|
68
|
-
- Commands may run under a file sandbox; a blocked file operation is reported as \`[sandbox: file access denied under <mode> mode]
|
|
69
|
-
- Non-zero exits are reported as \`[exit code: N]\`. Long output is truncated to its tail.
|
|
113
|
+
- Commands may run under a file sandbox; a blocked file operation is reported as \`[sandbox: file access denied under <mode> mode]\` — a policy denial, not a bug in the command; do not retry another way.
|
|
114
|
+
- Non-zero exits are reported as \`[exit code: N]\`. Current harness environment facts are exposed through managed \`$${DSH_ENV_PREFIX}*\` variables; inspect them when needed. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available.
|
|
115
|
+
- Set \`run_in_background: true\` for long-running commands: the call returns a job id immediately; read its output with \`job_output\` and stop it with \`job_kill\`. No timeout applies.
|
|
70
116
|
Bash idioms fish REJECTS with exit 127 — translate before running:
|
|
71
117
|
\`VAR=x\` / \`export VAR=x\` → \`set VAR x\` / \`set -gx VAR x\`
|
|
72
118
|
\`for i in ...; do ...; done\` → \`for i in ...; ...; end\`
|
|
@@ -74,6 +120,20 @@ Bash idioms fish REJECTS with exit 127 — translate before running:
|
|
|
74
120
|
\`cmd <<'EOF' ... EOF\` (heredoc) → \`printf '...' | cmd\` (fish has no heredoc)
|
|
75
121
|
If fish rejects your command, retry with the fish form from the table — failed results carry a [fish syntax] hint naming the exact fix.`
|
|
76
122
|
|
|
123
|
+
const ESCALATION_TOOL_DESCRIPTION = 'Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.'
|
|
124
|
+
|
|
125
|
+
const TOOL_DESCRIPTION = `${BASE_TOOL_DESCRIPTION}\n${ESCALATION_TOOL_DESCRIPTION}`
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The tool description for one composition: the escalation prose is appended
|
|
129
|
+
* only when the composition advertises escalation targets.
|
|
130
|
+
* @param {readonly import('@deepseek-ai/dsh-sandbox').SandboxMode[]} escalationModes - targets this composition advertises.
|
|
131
|
+
* @returns {string} the model-facing description.
|
|
132
|
+
*/
|
|
133
|
+
function fishDescription(escalationModes) {
|
|
134
|
+
return escalationModes.length === 0 ? BASE_TOOL_DESCRIPTION : TOOL_DESCRIPTION
|
|
135
|
+
}
|
|
136
|
+
|
|
77
137
|
/**
|
|
78
138
|
* Targeted fish-syntax correction for a failed run. Models default to bash
|
|
79
139
|
* idioms; when fish rejects a command the stderr carries a diagnostic like
|
|
@@ -108,7 +168,11 @@ const FISH_SYNTAX_HINTS = [
|
|
|
108
168
|
},
|
|
109
169
|
]
|
|
110
170
|
|
|
111
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Find a fish syntax hint for one stderr text, or '' when none applies.
|
|
173
|
+
* @param {string} stderrText - the failed run's stderr text.
|
|
174
|
+
* @returns {string} the mapped hint, or `''` when nothing applies.
|
|
175
|
+
*/
|
|
112
176
|
function fishSyntaxHint(stderrText) {
|
|
113
177
|
for (const { pattern, hint } of FISH_SYNTAX_HINTS) {
|
|
114
178
|
if (pattern.test(stderrText)) return hint
|
|
@@ -120,11 +184,19 @@ function fishSyntaxHint(stderrText) {
|
|
|
120
184
|
* Shape one finished run into the text the model sees: stdout, then a marked
|
|
121
185
|
* stderr section, then sandbox/timeout/signal/exit markers — the same marker
|
|
122
186
|
* contract the harness bash tool uses, so the model reads both shells
|
|
123
|
-
* identically.
|
|
124
|
-
*
|
|
187
|
+
* identically. A denied run under an escalation-advertising composition
|
|
188
|
+
* additionally carries the shared same-turn escalation hint.
|
|
189
|
+
* @param {FishForegroundValue} result - the canonical foreground value from the executor.
|
|
190
|
+
* @param {readonly string[]} [escalationModes] - the escalation targets this
|
|
191
|
+
* composition advertises; non-empty appends the same-turn escalation hint
|
|
192
|
+
* after a denial marker (default `[]`: no hint).
|
|
125
193
|
* @returns {string} the model-facing text.
|
|
126
194
|
*/
|
|
127
|
-
function renderResult(result) {
|
|
195
|
+
function renderResult(result, escalationModes = []) {
|
|
196
|
+
/**
|
|
197
|
+
* @param {FishOutputStream} stream - one captured stream of the canonical value.
|
|
198
|
+
* @returns {string} its text plus the truncation notice when it was cut.
|
|
199
|
+
*/
|
|
128
200
|
const streamText = (stream) => {
|
|
129
201
|
if (!stream.truncated) return stream.text
|
|
130
202
|
return `${stream.text}\n[output truncated${stream.spillPath !== undefined
|
|
@@ -152,7 +224,8 @@ function renderResult(result) {
|
|
|
152
224
|
|
|
153
225
|
const markers = []
|
|
154
226
|
if (result.sandbox?.denied) {
|
|
155
|
-
markers.push(
|
|
227
|
+
markers.push(sandboxDenialMarker(result.sandbox.mode))
|
|
228
|
+
if (escalationModes.length > 0) markers.push(escalationHintMarker('command'))
|
|
156
229
|
}
|
|
157
230
|
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs ?? 0}ms]`)
|
|
158
231
|
if (result.signal !== null) {
|
|
@@ -165,6 +238,211 @@ function renderResult(result) {
|
|
|
165
238
|
return body + markers.join('\n')
|
|
166
239
|
}
|
|
167
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Sandbox facts worth the job's terminal detail: a runner that never ran the
|
|
243
|
+
* command, or a denial (with the escalation hint this composition offers).
|
|
244
|
+
* The `ctx.jobs` registry owns the read path now, so these notes travel on the
|
|
245
|
+
* outcome's detail line — the one line every reader (the model's status line,
|
|
246
|
+
* the job roster row) shows — instead of being appended to each read.
|
|
247
|
+
* @param {import('@deepseek-ai/dsh-shell').ShellSandboxInfo | undefined} sandbox - settled sandbox facts, when this was a confined process.
|
|
248
|
+
* @param {readonly string[]} escalationModes - escalation targets advertised by this composition.
|
|
249
|
+
* @returns {string[]} the notes to append, oldest first.
|
|
250
|
+
*/
|
|
251
|
+
function sandboxNotes(sandbox, escalationModes) {
|
|
252
|
+
if (sandbox?.runnerFailed) {
|
|
253
|
+
return [`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`]
|
|
254
|
+
}
|
|
255
|
+
if (sandbox?.denied) {
|
|
256
|
+
const notes = [sandboxDenialMarker(sandbox.mode)]
|
|
257
|
+
if (escalationModes.length > 0) notes.push(escalationHintMarker('command'))
|
|
258
|
+
return notes
|
|
259
|
+
}
|
|
260
|
+
return []
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Map a settled background process onto the generic task-outcome vocabulary:
|
|
265
|
+
* `killed` stays `killed` (detail: the signal when one is known), everything
|
|
266
|
+
* else is `completed` with the exit code as detail. A nonzero command exit is
|
|
267
|
+
* reported, not failed, exactly like the foreground rendering. Sandbox facts
|
|
268
|
+
* join the detail, since a job's terminal reason is the one line every
|
|
269
|
+
* reader — the model's status line, the roster row — shows.
|
|
270
|
+
* @param {import('@deepseek-ai/dsh-shell').ShellProcess} proc - the settled process handle.
|
|
271
|
+
* @param {readonly string[]} [escalationModes] - escalation targets advertised by this composition.
|
|
272
|
+
* @returns {{ status: 'completed' | 'killed', detail: string }} the outcome for the `ctx.jobs` registration.
|
|
273
|
+
*/
|
|
274
|
+
function processOutcome(proc, escalationModes = []) {
|
|
275
|
+
/** @type {{ status: 'completed' | 'killed', detail: string }} */
|
|
276
|
+
const base = proc.status === 'killed'
|
|
277
|
+
? { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
|
|
278
|
+
: { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
|
|
279
|
+
const notes = sandboxNotes(proc.sandbox, escalationModes)
|
|
280
|
+
return notes.length === 0 ? base : {
|
|
281
|
+
...base,
|
|
282
|
+
detail: `${base.detail}; ${notes.join(' ')}`,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The process's non-consuming stream readers as `ctx.jobs` pull sources
|
|
288
|
+
* (ported from the official `@deepseek-ai/dsh-tool-bash`'s background
|
|
289
|
+
* adaptation: the registry pumps these instead of the removed
|
|
290
|
+
* `JobHooks.readOutput`). They bind lazily because the handle is published
|
|
291
|
+
* only after asynchronous preparation; a read before then yields nothing, and
|
|
292
|
+
* the pump keeps the model's consuming cursor untouched.
|
|
293
|
+
* @param {() => import('@deepseek-ai/dsh-shell').ShellProcess | undefined} process - resolves the published handle, once preparation has one.
|
|
294
|
+
* @returns {readonly import('@deepseek-ai/dsh-jobs').JobOutputSource[]} one source per stream, stdout first.
|
|
295
|
+
*/
|
|
296
|
+
function processSources(process) {
|
|
297
|
+
/**
|
|
298
|
+
* @param {'stdout' | 'stderr'} channel - stream label the registry attaches to this source's chunks.
|
|
299
|
+
* @returns {import('@deepseek-ai/dsh-jobs').JobOutputSource} one non-consuming pull source.
|
|
300
|
+
*/
|
|
301
|
+
const source = (channel) => ({
|
|
302
|
+
channel,
|
|
303
|
+
/** @param {number} fromByte - whole-stream offset to resume from (a prior read's `nextOffset`). */
|
|
304
|
+
read: (fromByte) => {
|
|
305
|
+
const live = process()
|
|
306
|
+
return live === undefined
|
|
307
|
+
? { text: '', nextOffset: fromByte, lossy: false }
|
|
308
|
+
: live.observed[channel].readFrom(fromByte)
|
|
309
|
+
},
|
|
310
|
+
})
|
|
311
|
+
return [source('stdout'), source('stderr')]
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Synchronous job hooks over asynchronous shell preparation (ported from the
|
|
316
|
+
* official `@deepseek-ai/dsh-tool-bash`): `ctx.jobs` admits the job and calls
|
|
317
|
+
* `run()` synchronously, while `ctx.shell.execute` publishes its handle only
|
|
318
|
+
* after preparation. Cancellation aborts the preparation signal (which also
|
|
319
|
+
* stops the command during preparation) and kills an already-published
|
|
320
|
+
* process; `done` spans preparation and settlement, and never rejects.
|
|
321
|
+
* @param {(signal: AbortSignal) => Promise<import('@deepseek-ai/dsh-shell').ShellExecution>} start - starts the process under a job-owned cancellation signal.
|
|
322
|
+
* @param {(proc: import('@deepseek-ai/dsh-shell').ShellProcess) => { status: 'completed' | 'killed', detail: string }} outcome - projects the settled process into the job outcome.
|
|
323
|
+
* @returns {{ cancel: (reason?: string) => void, done: Promise<{ status: 'completed' | 'killed' | 'failed', detail: string }> }} the job's hooks.
|
|
324
|
+
*/
|
|
325
|
+
function processJob(start, outcome) {
|
|
326
|
+
const controller = new AbortController()
|
|
327
|
+
/** @type {import('@deepseek-ai/dsh-shell').ShellExecution | undefined} */
|
|
328
|
+
let proc
|
|
329
|
+
return {
|
|
330
|
+
cancel: (reason) => {
|
|
331
|
+
if (controller.signal.aborted) return
|
|
332
|
+
controller.abort(reason)
|
|
333
|
+
proc?.kill()
|
|
334
|
+
},
|
|
335
|
+
done: (async () => {
|
|
336
|
+
try {
|
|
337
|
+
proc = await start(controller.signal)
|
|
338
|
+
try {
|
|
339
|
+
if (controller.signal.aborted) proc.kill()
|
|
340
|
+
} finally {
|
|
341
|
+
await proc.done
|
|
342
|
+
}
|
|
343
|
+
return outcome(proc)
|
|
344
|
+
} catch (error) {
|
|
345
|
+
return {
|
|
346
|
+
status: controller.signal.aborted && proc === undefined ? 'killed' : 'failed',
|
|
347
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
})(),
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Detach the executor DTO from readonly Service Definition types into plain JSON data.
|
|
356
|
+
* @param {import('@deepseek-ai/dsh-shell').ShellRunResult} result - the settled foreground result.
|
|
357
|
+
* @returns {FishForegroundValue} the canonical value declared by OUTPUT_SCHEMA.
|
|
358
|
+
*/
|
|
359
|
+
function canonicalFishResult(result) {
|
|
360
|
+
/**
|
|
361
|
+
* @param {import('@deepseek-ai/dsh-shell').CollectedOutput} s - one captured stream of the settled result.
|
|
362
|
+
* @returns {FishOutputStream} its lossless-JSON projection.
|
|
363
|
+
*/
|
|
364
|
+
const stream = (s) => ({
|
|
365
|
+
text: s.text,
|
|
366
|
+
truncated: s.truncated,
|
|
367
|
+
...s.spillPath !== undefined ? { spillPath: s.spillPath } : {},
|
|
368
|
+
})
|
|
369
|
+
return {
|
|
370
|
+
exitCode: result.exitCode,
|
|
371
|
+
signal: result.signal,
|
|
372
|
+
timedOut: result.timedOut,
|
|
373
|
+
aborted: result.aborted,
|
|
374
|
+
timeoutMs: result.timeoutMs,
|
|
375
|
+
stdout: stream(result.stdout),
|
|
376
|
+
stderr: stream(result.stderr),
|
|
377
|
+
...result.sandbox !== undefined ? { sandbox: {
|
|
378
|
+
mode: result.sandbox.mode,
|
|
379
|
+
denied: result.sandbox.denied,
|
|
380
|
+
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
|
|
381
|
+
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
|
|
382
|
+
} } : {},
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Present the PENDING state of one call in a UI: a foreground command is a
|
|
388
|
+
* terminal card (cwd-headed); a background call is a generic execute card.
|
|
389
|
+
* Total over malformed replay args: a non-object or a missing/non-string
|
|
390
|
+
* command/description returns `undefined` so the UI falls back to its
|
|
391
|
+
* generic presentation instead of throwing or minting an invalid title.
|
|
392
|
+
* @param {any} args - the call's parsed arguments.
|
|
393
|
+
* @returns {import('@deepseek-ai/dsh-tools').ToolCallView | undefined} the pending card intent.
|
|
394
|
+
*/
|
|
395
|
+
function presentFishCall(args) {
|
|
396
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined
|
|
397
|
+
if (typeof args.command !== 'string' || typeof args.description !== 'string') return undefined
|
|
398
|
+
if (args.run_in_background === true) {
|
|
399
|
+
return {
|
|
400
|
+
card: 'generic',
|
|
401
|
+
title: args.command,
|
|
402
|
+
kind: 'execute',
|
|
403
|
+
rawInput: args.command,
|
|
404
|
+
content: [{ type: 'text', text: args.description }],
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
card: 'terminal',
|
|
409
|
+
title: args.command,
|
|
410
|
+
description: args.description,
|
|
411
|
+
...typeof args.workdir === 'string' ? { cwd: args.workdir } : {},
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Present completed foreground output as a terminal card with an exit-status
|
|
417
|
+
* pill (recovered via `parseExitStatus`); background acknowledgements and
|
|
418
|
+
* execution errors use generic fenced output without an exit-status pill.
|
|
419
|
+
* Total over malformed replay args/results: invalid inputs return
|
|
420
|
+
* `undefined` (generic UI fallback) instead of throwing.
|
|
421
|
+
* @param {any} args - the call's parsed arguments.
|
|
422
|
+
* @param {any} result - the durable result projection (validated here, so malformed replays cannot throw).
|
|
423
|
+
* @returns {import('@deepseek-ai/dsh-tools').ToolResultView | undefined} the completed card intent.
|
|
424
|
+
*/
|
|
425
|
+
function presentFishResult(args, result) {
|
|
426
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined
|
|
427
|
+
if (typeof args.command !== 'string') return undefined
|
|
428
|
+
if (typeof result !== 'object' || result === null || Array.isArray(result) || !Array.isArray(result.content)) return undefined
|
|
429
|
+
const block = result.content.length === 1 ? result.content[0] : undefined
|
|
430
|
+
if (block === undefined || block.type !== 'text') return undefined
|
|
431
|
+
const raw = block.text
|
|
432
|
+
if (args.run_in_background === true || result.isError) {
|
|
433
|
+
return {
|
|
434
|
+
card: 'generic',
|
|
435
|
+
content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }],
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const { body, ...exit } = parseExitStatus(raw)
|
|
439
|
+
return {
|
|
440
|
+
card: 'terminal',
|
|
441
|
+
output: body,
|
|
442
|
+
...exit,
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
168
446
|
/** Object-root JSON Schema for the tool's parameters (raw-registered). */
|
|
169
447
|
const PARAMETERS = {
|
|
170
448
|
type: 'object',
|
|
@@ -193,54 +471,110 @@ const PARAMETERS = {
|
|
|
193
471
|
},
|
|
194
472
|
}
|
|
195
473
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
474
|
+
/**
|
|
475
|
+
* The tool's parameters for one composition: the base parameters plus
|
|
476
|
+
* `run_in_background`, and the escalation fields only when the mounted
|
|
477
|
+
* executor advertises confinement (an unadvertised field is never parsed).
|
|
478
|
+
* @param {readonly import('@deepseek-ai/dsh-sandbox').SandboxMode[]} escalationModes - targets the composition advertises; empty hides the escalation fields.
|
|
479
|
+
*/
|
|
480
|
+
function fishParameters(escalationModes) {
|
|
481
|
+
return {
|
|
482
|
+
...PARAMETERS,
|
|
201
483
|
properties: {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
timeoutMs: { type: 'number' },
|
|
208
|
-
stdout: {
|
|
209
|
-
type: 'object',
|
|
210
|
-
additionalProperties: false,
|
|
211
|
-
required: ['text', 'truncated'],
|
|
212
|
-
properties: {
|
|
213
|
-
text: { type: 'string' },
|
|
214
|
-
truncated: { type: 'boolean' },
|
|
215
|
-
spillPath: { type: 'string' },
|
|
216
|
-
},
|
|
484
|
+
...PARAMETERS.properties,
|
|
485
|
+
run_in_background: {
|
|
486
|
+
type: 'boolean',
|
|
487
|
+
description: 'Run in the background and return a job id immediately '
|
|
488
|
+
+ '(collect with job_output, stop with job_kill). No timeout applies.',
|
|
217
489
|
},
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
490
|
+
...escalationModes.length > 0 ? {
|
|
491
|
+
sandbox_permissions: {
|
|
492
|
+
type: 'string',
|
|
493
|
+
enum: [...escalationModes],
|
|
494
|
+
description: 'The wider sandbox mode this command needs. Only valid as a one-shot '
|
|
495
|
+
+ 'retry of a command the sandbox just denied; requires justification and user approval.',
|
|
496
|
+
},
|
|
497
|
+
justification: {
|
|
498
|
+
type: 'string',
|
|
499
|
+
description: 'Required with sandbox_permissions: one sentence for the user explaining '
|
|
500
|
+
+ 'why this exact command needs the wider access.',
|
|
226
501
|
},
|
|
502
|
+
} : {},
|
|
503
|
+
},
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Canonical output declaration: a background acknowledgment or a foreground
|
|
508
|
+
* run. The rc.1 ToolRuntime dialect forbids `type`/`additionalProperties`
|
|
509
|
+
* beside a `oneOf` root (exact-one branches), so the union is expressed as a
|
|
510
|
+
* bare `oneOf` with each branch carrying its own object shape.
|
|
511
|
+
* @type {import('@deepseek-ai/dsh-tools').JsonSchemaNode} */
|
|
512
|
+
const OUTPUT_SCHEMA = {
|
|
513
|
+
oneOf: [
|
|
514
|
+
{
|
|
515
|
+
type: 'object',
|
|
516
|
+
additionalProperties: false,
|
|
517
|
+
properties: {
|
|
518
|
+
kind: { type: 'string', const: 'background' },
|
|
519
|
+
jobId: { type: 'string' },
|
|
227
520
|
},
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
521
|
+
required: ['kind', 'jobId'],
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
type: 'object',
|
|
525
|
+
additionalProperties: false,
|
|
526
|
+
properties: {
|
|
527
|
+
kind: { type: 'string', const: 'foreground' },
|
|
528
|
+
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
529
|
+
signal: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
530
|
+
timedOut: { type: 'boolean' },
|
|
531
|
+
aborted: { type: 'boolean' },
|
|
532
|
+
timeoutMs: { type: 'number' },
|
|
533
|
+
stdout: {
|
|
534
|
+
type: 'object',
|
|
535
|
+
additionalProperties: false,
|
|
536
|
+
properties: {
|
|
537
|
+
text: { type: 'string' },
|
|
538
|
+
truncated: { type: 'boolean' },
|
|
539
|
+
spillPath: { type: 'string' },
|
|
540
|
+
},
|
|
541
|
+
required: ['text', 'truncated'],
|
|
542
|
+
},
|
|
543
|
+
stderr: {
|
|
544
|
+
type: 'object',
|
|
545
|
+
additionalProperties: false,
|
|
546
|
+
properties: {
|
|
547
|
+
text: { type: 'string' },
|
|
548
|
+
truncated: { type: 'boolean' },
|
|
549
|
+
spillPath: { type: 'string' },
|
|
550
|
+
},
|
|
551
|
+
required: ['text', 'truncated'],
|
|
552
|
+
},
|
|
553
|
+
sandbox: {
|
|
554
|
+
type: 'object',
|
|
555
|
+
additionalProperties: false,
|
|
556
|
+
properties: {
|
|
557
|
+
mode: { type: 'string' },
|
|
558
|
+
denied: { type: 'boolean' },
|
|
559
|
+
enforcement: { type: 'string' },
|
|
560
|
+
runnerFailed: { type: 'boolean' },
|
|
561
|
+
},
|
|
562
|
+
required: ['mode', 'denied'],
|
|
235
563
|
},
|
|
236
564
|
},
|
|
565
|
+
required: ['kind', 'exitCode', 'signal', 'timedOut', 'aborted', 'timeoutMs', 'stdout', 'stderr'],
|
|
237
566
|
},
|
|
238
|
-
|
|
239
|
-
render: (_args, value) => [{ type: 'text', text: renderResult(value) }],
|
|
567
|
+
],
|
|
240
568
|
}
|
|
241
569
|
|
|
242
|
-
/**
|
|
243
|
-
*
|
|
570
|
+
/**
|
|
571
|
+
* Resolve an explicit workdir: the sandbox policy's workspace root wins as
|
|
572
|
+
* the base, then the session cwd; a relative path resolves against it.
|
|
573
|
+
* @param {string | undefined} modelWorkdir - the model's `workdir` argument.
|
|
574
|
+
* @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec - the tool execution.
|
|
575
|
+
* @param {string | undefined} policyWorkspaceRoot - the standing policy's workspace root.
|
|
576
|
+
* @returns {string | undefined} the workdir for this call, or `undefined` to let the executor default it.
|
|
577
|
+
*/
|
|
244
578
|
function resolveWorkdir(modelWorkdir, exec, policyWorkspaceRoot) {
|
|
245
579
|
const sessionCwd = policyWorkspaceRoot ?? exec?.agent?.session?.header?.cwd
|
|
246
580
|
if (modelWorkdir === undefined) return sessionCwd
|
|
@@ -251,29 +585,75 @@ function resolveWorkdir(modelWorkdir, exec, policyWorkspaceRoot) {
|
|
|
251
585
|
}
|
|
252
586
|
|
|
253
587
|
/**
|
|
254
|
-
* Cordis plugin entry:
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
588
|
+
* Cordis plugin entry: register the `fish` tool once the tool registry, the
|
|
589
|
+
* `fish` executor, and the system-prompt registry are up. The per-agent fish
|
|
590
|
+
* policy lives in the sibling `policy.js` module (mounted by the bundle
|
|
591
|
+
* patch).
|
|
258
592
|
* @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
|
|
259
593
|
*/
|
|
260
594
|
export function apply(ctx) {
|
|
261
|
-
installPreset()
|
|
262
|
-
|
|
263
595
|
// Mirror dsh-tool-bash's policy path: when the mounted executor confines,
|
|
264
596
|
// resolve each call's standing policy against the calling session so
|
|
265
597
|
// /permission switches and the session workspace are honored, and fail loud
|
|
266
598
|
// at load when the policy owner is missing.
|
|
267
599
|
const defaultMode = ctx.shell.sandboxMode
|
|
600
|
+
const escalationModes = defaultMode === undefined ? [] : ESCALATION_TARGETS
|
|
268
601
|
const sandboxPolicy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
|
|
269
602
|
if (defaultMode !== undefined && sandboxPolicy === undefined) {
|
|
270
603
|
throw new Error('tool-fish: the mounted fish executor confines but ctx.sandboxPolicy is missing')
|
|
271
604
|
}
|
|
605
|
+
/**
|
|
606
|
+
* @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec - the tool execution.
|
|
607
|
+
* @returns {import('@deepseek-ai/dsh-sandbox').SandboxExecutionPolicy | undefined} the standing policy for this call's session, or `undefined` without a policy owner.
|
|
608
|
+
*/
|
|
272
609
|
const resolveSandboxPolicy = (exec) => {
|
|
273
610
|
if (sandboxPolicy === undefined) return undefined
|
|
274
611
|
return sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
|
|
275
612
|
}
|
|
276
613
|
|
|
614
|
+
/**
|
|
615
|
+
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
|
|
616
|
+
* anything executes, delegating the shared fail-closed sequence (strict
|
|
617
|
+
* widening, channel resolution, outcome mapping) to approveEscalation —
|
|
618
|
+
* the same composition guard and ingredients as the official bash tool.
|
|
619
|
+
* @param {string} mode - the requested `sandbox_permissions` value (the ladder check lives in `approveEscalation`).
|
|
620
|
+
* @param {string} justification - the model's one-sentence reason, shown verbatim to the user.
|
|
621
|
+
* @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec - the tool execution awaiting the decision.
|
|
622
|
+
* @param {import('@deepseek-ai/dsh-sandbox').SandboxExecutionPolicy} standingPolicy - the call's standing policy (present whenever escalation is advertised).
|
|
623
|
+
* @returns {Promise<import('@deepseek-ai/dsh-sandbox').SandboxMode>} the granted mode, consumed by this one call.
|
|
624
|
+
*/
|
|
625
|
+
const approveFishEscalation = (mode, justification, exec, standingPolicy) => {
|
|
626
|
+
if (escalationModes.length === 0) {
|
|
627
|
+
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
|
628
|
+
}
|
|
629
|
+
const effectiveMode = standingPolicy.mode
|
|
630
|
+
return approveEscalation({
|
|
631
|
+
requestedMode: mode,
|
|
632
|
+
justification,
|
|
633
|
+
effectiveMode,
|
|
634
|
+
subject: 'command',
|
|
635
|
+
}, {
|
|
636
|
+
approver: ctx.get('approval'),
|
|
637
|
+
agent: exec.agent,
|
|
638
|
+
callId: exec.callId,
|
|
639
|
+
toolName: 'fish',
|
|
640
|
+
signal: exec.signal,
|
|
641
|
+
})
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
ctx.systemPrompt.section({
|
|
645
|
+
name: 'tool:fish',
|
|
646
|
+
order: ctx.systemPrompt.getSectionOrder('TOOL_BASH'),
|
|
647
|
+
text: 'Check the [exit code: N] marker on every fish result; investigate failures before moving on.',
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* One `fish` tool call: validate the model's arguments, resolve the standing
|
|
652
|
+
* policy and any same-turn escalation, then run through `ctx.shell`.
|
|
653
|
+
* @param {FishToolArgs} args - the parsed call arguments (validated below).
|
|
654
|
+
* @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec - the tool execution identity, agent, and cancellation.
|
|
655
|
+
* @returns {Promise<{ kind: 'foreground' } & FishForegroundValue | { kind: 'background', jobId: string }>} the canonical value declared by OUTPUT_SCHEMA.
|
|
656
|
+
*/
|
|
277
657
|
async function execute(args, exec) {
|
|
278
658
|
if (typeof args.command !== 'string' || args.command.trim().length === 0) {
|
|
279
659
|
throw new Error('invalid command: expected a non-empty string')
|
|
@@ -284,54 +664,108 @@ export function apply(ctx) {
|
|
|
284
664
|
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
285
665
|
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
|
286
666
|
}
|
|
667
|
+
validateEscalationArgs(args.sandbox_permissions, args.justification)
|
|
287
668
|
const standingPolicy = resolveSandboxPolicy(exec)
|
|
669
|
+
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
|
670
|
+
// `escalationModes` is non-empty exactly when a policy owner is mounted,
|
|
671
|
+
// so an escalation request always has a standing policy to widen.
|
|
672
|
+
? await approveFishEscalation(args.sandbox_permissions, args.justification, exec, /** @type {import('@deepseek-ai/dsh-sandbox').SandboxExecutionPolicy} */ (standingPolicy))
|
|
673
|
+
: undefined
|
|
674
|
+
// A one-shot escalation widens the standing policy for this call only.
|
|
675
|
+
// `apply` fails loud when a confining executor has no policy owner, so an
|
|
676
|
+
// approved mode implies a definite standing policy — the second guard makes
|
|
677
|
+
// that explicit for the spread instead of spreading a possible `undefined`.
|
|
678
|
+
const policy = approvedMode !== undefined && standingPolicy !== undefined
|
|
679
|
+
? { ...standingPolicy, mode: approvedMode }
|
|
680
|
+
: standingPolicy
|
|
288
681
|
const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot)
|
|
289
682
|
const request = {
|
|
290
683
|
command: args.command,
|
|
291
684
|
...workdir !== undefined ? { workdir } : {},
|
|
292
685
|
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
|
293
686
|
dshEnv: ctx.shellEnv.collect(exec),
|
|
294
|
-
...
|
|
687
|
+
...policy !== undefined ? { sandboxPolicy: policy } : {},
|
|
688
|
+
}
|
|
689
|
+
if (args.run_in_background === true) {
|
|
690
|
+
const jobs = ctx.get('jobs')
|
|
691
|
+
if (jobs === undefined) {
|
|
692
|
+
throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
|
|
693
|
+
}
|
|
694
|
+
if (exec.signal.aborted) {
|
|
695
|
+
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
|
696
|
+
error.name = 'AbortError'
|
|
697
|
+
throw error
|
|
698
|
+
}
|
|
699
|
+
/** @type {import('@deepseek-ai/dsh-shell').ShellExecution | undefined} */
|
|
700
|
+
let proc
|
|
701
|
+
return {
|
|
702
|
+
kind: 'background',
|
|
703
|
+
jobId: jobs.start({
|
|
704
|
+
kind: 'fish',
|
|
705
|
+
label: args.command,
|
|
706
|
+
// The registry fences access by SESSION id: `owner` is the owning
|
|
707
|
+
// session, not the live agent object (`JobSpec.owner?: SessionId`).
|
|
708
|
+
...exec.agent !== undefined ? { owner: exec.agent.id } : {},
|
|
709
|
+
// The registry pumps output itself (0.1.7-rc.2 removed the
|
|
710
|
+
// `JobHooks.readOutput` consumer), so the process's streams are
|
|
711
|
+
// passed as pull sources bound to the handle published below.
|
|
712
|
+
output: processSources(() => proc),
|
|
713
|
+
run: () => processJob(async (signal) => {
|
|
714
|
+
// Background work carries NO deadline: 0.1.5's `start()` armed no
|
|
715
|
+
// timer at all (`startArgv` spawned with the caller's signal while
|
|
716
|
+
// only `runArgv` built a `deadline(...)`), and the official
|
|
717
|
+
// 0.1.7 `dsh-tool-bash` resolves its job lane the same way.
|
|
718
|
+
// Without `onExpiry: 'none'` the resolved spec would default to
|
|
719
|
+
// `'kill'` and silently reintroduce a timeout the documented
|
|
720
|
+
// contract ("No timeout applies") never had.
|
|
721
|
+
const spec = ctx.shell.resolve({ ...request, onExpiry: 'none' })
|
|
722
|
+
proc = await ctx.shell.execute({ ...spec, signal })
|
|
723
|
+
return proc
|
|
724
|
+
}, (started) => processOutcome(started, escalationModes)),
|
|
725
|
+
}),
|
|
726
|
+
}
|
|
295
727
|
}
|
|
296
|
-
const result = await ctx.shell.
|
|
728
|
+
const result = await (await ctx.shell.execute(ctx.shell.resolve({
|
|
297
729
|
...request,
|
|
298
730
|
signal: exec?.signal,
|
|
299
|
-
}))
|
|
731
|
+
}))).result()
|
|
300
732
|
if (result.aborted) {
|
|
301
|
-
const error = new
|
|
733
|
+
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
|
302
734
|
error.name = 'AbortError'
|
|
303
735
|
throw error
|
|
304
736
|
}
|
|
305
|
-
const stream = (s) => ({
|
|
306
|
-
text: s.text,
|
|
307
|
-
truncated: s.truncated,
|
|
308
|
-
...s.spillPath !== undefined ? { spillPath: s.spillPath } : {},
|
|
309
|
-
})
|
|
310
737
|
return {
|
|
311
738
|
kind: 'foreground',
|
|
312
|
-
|
|
313
|
-
signal: result.signal,
|
|
314
|
-
timedOut: result.timedOut,
|
|
315
|
-
aborted: result.aborted,
|
|
316
|
-
timeoutMs: result.timeoutMs,
|
|
317
|
-
stdout: stream(result.stdout),
|
|
318
|
-
stderr: stream(result.stderr),
|
|
319
|
-
...result.sandbox !== undefined
|
|
320
|
-
? { sandbox: { mode: result.sandbox.mode, denied: result.sandbox.denied } }
|
|
321
|
-
: {},
|
|
739
|
+
...canonicalFishResult(result),
|
|
322
740
|
}
|
|
323
741
|
}
|
|
324
742
|
|
|
325
743
|
ctx.tools.register({
|
|
326
744
|
name: 'fish',
|
|
327
|
-
description:
|
|
328
|
-
parameters:
|
|
329
|
-
output:
|
|
745
|
+
description: fishDescription(escalationModes),
|
|
746
|
+
parameters: fishParameters(escalationModes),
|
|
747
|
+
output: {
|
|
748
|
+
schema: OUTPUT_SCHEMA,
|
|
749
|
+
render: (_args, value) => {
|
|
750
|
+
// The canonical value crosses the lossless-JSON boundary, so it
|
|
751
|
+
// arrives as `JsonValue`; OUTPUT_SCHEMA is what guarantees the shape.
|
|
752
|
+
const record = typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {}
|
|
753
|
+
return [{
|
|
754
|
+
type: 'text',
|
|
755
|
+
text: record.kind === 'background'
|
|
756
|
+
? `started background job ${String(record.jobId)}`
|
|
757
|
+
: renderResult(/** @type {FishForegroundValue} */ (value), escalationModes),
|
|
758
|
+
}]
|
|
759
|
+
},
|
|
760
|
+
},
|
|
330
761
|
execute,
|
|
762
|
+
presentCall: presentFishCall,
|
|
763
|
+
presentResult: presentFishResult,
|
|
331
764
|
})
|
|
332
765
|
}
|
|
333
766
|
|
|
334
|
-
// Deterministic test surface: the tool description
|
|
335
|
-
// pure, so unit tests can assert the bash→fish
|
|
336
|
-
//
|
|
337
|
-
|
|
767
|
+
// Deterministic test surface: the tool description, the result renderers, and
|
|
768
|
+
// the UI presenters are pure, so unit tests can assert the bash→fish
|
|
769
|
+
// guidance, the syntax-hint injection, and the escalation-aware rendering
|
|
770
|
+
// without booting a harness or running a live shell.
|
|
771
|
+
export { FISH_SYNTAX_HINTS, TOOL_DESCRIPTION, fishDescription, fishSyntaxHint, presentFishCall, presentFishResult, renderResult }
|